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
55type OpenLeaseId = u64;
56
57static DATABASE_OPEN_WAIT_COUNT: AtomicU64 = AtomicU64::new(0);
58static DATABASE_OPEN_FAILURE_COUNT: AtomicU64 = AtomicU64::new(0);
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct DatabaseOpenMetrics {
62    pub lock_waits: u64,
63    pub failures: u64,
64}
65
66#[derive(Clone, Debug, Eq, Hash, PartialEq)]
67enum DatabaseOpenKey {
68    IntendedPath(PathBuf),
69    FileIdentity(crate::durable_file::DurableFileIdentity),
70}
71
72#[derive(Debug)]
73enum ProcessOpenState {
74    Opening { lease_id: OpenLeaseId },
75    Open { lease_id: OpenLeaseId },
76    Closing { lease_id: OpenLeaseId },
77}
78
79impl ProcessOpenState {
80    fn lease_id(&self) -> OpenLeaseId {
81        match self {
82            Self::Opening { lease_id } | Self::Open { lease_id } | Self::Closing { lease_id } => {
83                *lease_id
84            }
85        }
86    }
87}
88
89#[derive(Default)]
90struct ProcessOpenRegistry {
91    next_lease_id: OpenLeaseId,
92    entries: HashMap<DatabaseOpenKey, ProcessOpenState>,
93}
94
95fn process_open_registry() -> &'static Mutex<ProcessOpenRegistry> {
96    static REGISTRY: std::sync::OnceLock<Mutex<ProcessOpenRegistry>> = std::sync::OnceLock::new();
97    REGISTRY.get_or_init(|| Mutex::new(ProcessOpenRegistry::default()))
98}
99
100fn same_process_locked(path: &Path) -> MongrelError {
101    MongrelError::DatabaseLocked {
102        path: path.to_path_buf(),
103        message: "database is already open in this process; reuse the existing Arc<Database>"
104            .into(),
105    }
106}
107
108struct OpenReservation {
109    lease_id: OpenLeaseId,
110    keys: Vec<DatabaseOpenKey>,
111    committed: bool,
112}
113
114impl OpenReservation {
115    fn acquire(key: DatabaseOpenKey, display_path: &Path) -> Result<Self> {
116        let mut registry = process_open_registry().lock();
117        if registry.entries.contains_key(&key) {
118            DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
119            return Err(same_process_locked(display_path));
120        }
121        registry.next_lease_id = registry.next_lease_id.checked_add(1).ok_or_else(|| {
122            MongrelError::Full("process database-open lease namespace exhausted".into())
123        })?;
124        let lease_id = registry.next_lease_id;
125        registry
126            .entries
127            .insert(key.clone(), ProcessOpenState::Opening { lease_id });
128        Ok(Self {
129            lease_id,
130            keys: vec![key],
131            committed: false,
132        })
133    }
134
135    fn into_lease(
136        mut self,
137        bootstrap_file: std::fs::File,
138        canonical_path: PathBuf,
139    ) -> ExclusiveDatabaseLease {
140        self.committed = true;
141        ExclusiveDatabaseLease {
142            lease_id: self.lease_id,
143            keys: std::mem::take(&mut self.keys),
144            bootstrap_file,
145            legacy_file: None,
146            canonical_path,
147            durable_root: None,
148            owner_pid: std::process::id(),
149            opened: false,
150        }
151    }
152}
153
154impl Drop for OpenReservation {
155    fn drop(&mut self) {
156        if self.committed {
157            return;
158        }
159        DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
160        let mut registry = process_open_registry().lock();
161        for key in &self.keys {
162            if registry
163                .entries
164                .get(key)
165                .is_some_and(|state| state.lease_id() == self.lease_id)
166            {
167                registry.entries.remove(key);
168            }
169        }
170    }
171}
172
173struct ExclusiveDatabaseLease {
174    lease_id: OpenLeaseId,
175    keys: Vec<DatabaseOpenKey>,
176    bootstrap_file: std::fs::File,
177    legacy_file: Option<std::fs::File>,
178    canonical_path: PathBuf,
179    durable_root: Option<Arc<crate::durable_file::DurableRoot>>,
180    owner_pid: u32,
181    opened: bool,
182}
183
184impl ExclusiveDatabaseLease {
185    fn claim_root_identity(&mut self, root: &crate::durable_file::DurableRoot) -> Result<()> {
186        let key = DatabaseOpenKey::FileIdentity(root.file_identity()?);
187        if self.keys.contains(&key) {
188            return Ok(());
189        }
190        let mut registry = process_open_registry().lock();
191        if registry.entries.contains_key(&key) {
192            return Err(same_process_locked(&self.canonical_path));
193        }
194        registry.entries.insert(
195            key.clone(),
196            ProcessOpenState::Opening {
197                lease_id: self.lease_id,
198            },
199        );
200        self.keys.push(key);
201        Ok(())
202    }
203
204    fn mark_open(&mut self) -> Result<()> {
205        let mut registry = process_open_registry().lock();
206        if self.keys.iter().any(|key| {
207            registry
208                .entries
209                .get(key)
210                .is_none_or(|state| state.lease_id() != self.lease_id)
211        }) {
212            return Err(MongrelError::Conflict(
213                "database-open reservation changed during initialization".into(),
214            ));
215        }
216        for key in &self.keys {
217            registry.entries.insert(
218                key.clone(),
219                ProcessOpenState::Open {
220                    lease_id: self.lease_id,
221                },
222            );
223        }
224        self.opened = true;
225        Ok(())
226    }
227}
228
229impl Drop for ExclusiveDatabaseLease {
230    fn drop(&mut self) {
231        if std::process::id() != self.owner_pid {
232            return;
233        }
234        if !self.opened {
235            DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
236        }
237        {
238            let mut registry = process_open_registry().lock();
239            for key in &self.keys {
240                if registry
241                    .entries
242                    .get(key)
243                    .is_some_and(|state| state.lease_id() == self.lease_id)
244                {
245                    registry.entries.insert(
246                        key.clone(),
247                        ProcessOpenState::Closing {
248                            lease_id: self.lease_id,
249                        },
250                    );
251                }
252            }
253        }
254        if let Some(file) = &self.legacy_file {
255            let _ = fs2::FileExt::unlock(file);
256        }
257        let _ = fs2::FileExt::unlock(&self.bootstrap_file);
258        let mut registry = process_open_registry().lock();
259        for key in &self.keys {
260            if registry
261                .entries
262                .get(key)
263                .is_some_and(|state| state.lease_id() == self.lease_id)
264            {
265                registry.entries.remove(key);
266            }
267        }
268    }
269}
270
271fn commit_prepare_checkpoint(
272    control: Option<&crate::ExecutionControl>,
273    index: usize,
274) -> Result<()> {
275    if index.is_multiple_of(256) {
276        if let Some(control) = control {
277            control.checkpoint()?;
278        }
279    }
280    Ok(())
281}
282
283fn finish_controlled_commit_attempt(
284    result: Result<Epoch>,
285    after_commit: &mut Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
286) -> Result<Epoch> {
287    let Some(after_commit) = after_commit.as_mut() else {
288        return result;
289    };
290    match result {
291        Ok(epoch) => match (**after_commit)(Some(epoch)) {
292            Ok(()) => Ok(epoch),
293            Err(error) => Err(MongrelError::DurableCommit {
294                epoch: epoch.0,
295                message: error.to_string(),
296            }),
297        },
298        Err(MongrelError::DurableCommit { epoch, message }) => {
299            let callback_error = (**after_commit)(Some(Epoch(epoch))).err();
300            Err(MongrelError::DurableCommit {
301                epoch,
302                message: callback_error
303                    .map(|error| format!("{message}; commit callback: {error}"))
304                    .unwrap_or(message),
305            })
306        }
307        Err(error) => match (**after_commit)(None) {
308            Ok(()) => Err(error),
309            Err(callback_error) => Err(MongrelError::Other(format!(
310                "{error}; commit callback: {callback_error}"
311            ))),
312        },
313    }
314}
315
316fn current_unix_nanos() -> u64 {
317    std::time::SystemTime::now()
318        .duration_since(std::time::UNIX_EPOCH)
319        .unwrap_or_default()
320        .as_nanos() as u64
321}
322
323#[cfg(feature = "encryption")]
324fn read_encryption_salt(
325    root: &crate::durable_file::DurableRoot,
326) -> Result<[u8; crate::encryption::SALT_LEN]> {
327    let mut file = root
328        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
329        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
330    let length = file.metadata()?.len();
331    if length != crate::encryption::SALT_LEN as u64 {
332        return Err(MongrelError::Encryption(format!(
333            "invalid encryption salt length: got {length}, expected {}",
334            crate::encryption::SALT_LEN
335        )));
336    }
337    let mut salt = [0_u8; crate::encryption::SALT_LEN];
338    file.read_exact(&mut salt)?;
339    Ok(salt)
340}
341
342fn incremental_aggregate_cache_key(
343    table: &str,
344    conditions: &[crate::query::Condition],
345    column: Option<u16>,
346    agg: crate::engine::NativeAgg,
347    principal: Option<&crate::auth::Principal>,
348    security_version: u64,
349) -> u64 {
350    use std::hash::{Hash, Hasher};
351    let projection = column.as_ref().map(std::slice::from_ref);
352    let query_key = crate::query::canonical_query_key(conditions, projection, security_version);
353    let mut hasher = std::collections::hash_map::DefaultHasher::new();
354    table.hash(&mut hasher);
355    query_key.hash(&mut hasher);
356    match agg {
357        crate::engine::NativeAgg::Count => 0u8,
358        crate::engine::NativeAgg::Sum => 1,
359        crate::engine::NativeAgg::Min => 2,
360        crate::engine::NativeAgg::Max => 3,
361        crate::engine::NativeAgg::Avg => 4,
362    }
363    .hash(&mut hasher);
364    if let Some(principal) = principal {
365        principal.user_id.hash(&mut hasher);
366        principal.created_epoch.hash(&mut hasher);
367        principal.username.hash(&mut hasher);
368        principal.is_admin.hash(&mut hasher);
369        let mut roles = principal.roles.clone();
370        roles.sort_unstable();
371        roles.hash(&mut hasher);
372    }
373    hasher.finish()
374}
375
376fn read_history_retention(
377    root: &crate::durable_file::DurableRoot,
378    current_epoch: Epoch,
379) -> Result<(u64, Epoch)> {
380    const MAX_HISTORY_RETENTION_BYTES: u64 = 128;
381    let file = match root.open_regular(Path::new(META_DIR).join(HISTORY_RETENTION_FILENAME)) {
382        Ok(file) => file,
383        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
384            return Ok((0, current_epoch));
385        }
386        Err(error) => return Err(error.into()),
387    };
388    let length = file.metadata()?.len();
389    if length > MAX_HISTORY_RETENTION_BYTES {
390        return Err(MongrelError::ResourceLimitExceeded {
391            resource: "history retention bytes",
392            requested: usize::try_from(length).unwrap_or(usize::MAX),
393            limit: MAX_HISTORY_RETENTION_BYTES as usize,
394        });
395    }
396    let mut bytes = Vec::with_capacity(length as usize);
397    file.take(MAX_HISTORY_RETENTION_BYTES + 1)
398        .read_to_end(&mut bytes)?;
399    if bytes.len() as u64 != length {
400        return Err(MongrelError::Other(
401            "history retention length changed while reading".into(),
402        ));
403    }
404    let text = std::str::from_utf8(&bytes)
405        .map_err(|error| MongrelError::Other(format!("history retention encoding: {error}")))?;
406    let mut fields = text.split_whitespace();
407    let epochs = fields
408        .next()
409        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
410        .parse::<u64>()
411        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
412    let start = fields
413        .next()
414        .ok_or_else(|| MongrelError::Other("history retention start is missing".into()))?
415        .parse::<u64>()
416        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
417    if fields.next().is_some() || start > current_epoch.0 {
418        return Err(MongrelError::Other(
419            "history retention file has trailing fields or a future start epoch".into(),
420        ));
421    }
422    Ok((epochs, Epoch(start)))
423}
424
425fn write_history_retention<F>(
426    root: &Path,
427    epochs: u64,
428    start: Epoch,
429    after_publish: F,
430) -> Result<()>
431where
432    F: FnOnce(),
433{
434    let meta = root.join(META_DIR);
435    let path = meta.join(HISTORY_RETENTION_FILENAME);
436    let bytes = format!("{epochs} {}\n", start.0);
437    crate::durable_file::write_atomic_with_after(&path, bytes.as_bytes(), after_publish)?;
438    Ok(())
439}
440
441struct PreparedBackupDestination {
442    parent: crate::durable_file::DurableRoot,
443    destination_name: std::ffi::OsString,
444    destination_path: PathBuf,
445    stage_name: std::ffi::OsString,
446    stage: Option<Box<crate::durable_file::DurableRoot>>,
447}
448
449fn prepare_backup_destination(
450    source: &Path,
451    destination: &Path,
452) -> Result<PreparedBackupDestination> {
453    let destination_name = destination
454        .file_name()
455        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?
456        .to_os_string();
457    let requested_parent = destination
458        .parent()
459        .filter(|path| !path.as_os_str().is_empty())
460        .unwrap_or_else(|| Path::new("."));
461    crate::durable_file::create_directory_all(requested_parent)?;
462    let parent = crate::durable_file::DurableRoot::open(requested_parent)?;
463    prepare_backup_destination_in(source, &parent, &destination_name)
464}
465
466fn prepare_backup_destination_in(
467    source: &Path,
468    parent: &crate::durable_file::DurableRoot,
469    destination_name: &std::ffi::OsStr,
470) -> Result<PreparedBackupDestination> {
471    let source = source.canonicalize()?;
472    if parent.canonical_path().starts_with(&source) {
473        return Err(MongrelError::InvalidArgument(
474            "backup destination must not be inside the source database".into(),
475        ));
476    }
477    if parent.entry_exists(Path::new(&destination_name))? {
478        return Err(MongrelError::Conflict(format!(
479            "backup destination already exists: {}",
480            parent.canonical_path().join(destination_name).display()
481        )));
482    }
483    let mut stage_name = None;
484    for _ in 0..128 {
485        let mut nonce = [0_u8; 8];
486        crate::encryption::fill_random(&mut nonce)?;
487        let suffix = nonce
488            .iter()
489            .map(|byte| format!("{byte:02x}"))
490            .collect::<String>();
491        let name = std::ffi::OsString::from(format!(
492            ".{}.backup-stage-{}-{suffix}",
493            destination_name.to_string_lossy(),
494            std::process::id()
495        ));
496        match parent.create_directory_new(Path::new(&name)) {
497            Ok(()) => {
498                stage_name = Some(name);
499                break;
500            }
501            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
502            Err(error) => return Err(error.into()),
503        }
504    }
505    let stage_name = stage_name
506        .ok_or_else(|| MongrelError::Conflict("could not allocate backup staging path".into()))?;
507    let stage = parent.open_directory(Path::new(&stage_name))?;
508    Ok(PreparedBackupDestination {
509        destination_path: parent.canonical_path().join(destination_name),
510        destination_name: destination_name.to_os_string(),
511        stage_name,
512        stage: Some(Box::new(stage)),
513        parent: parent.try_clone()?,
514    })
515}
516
517fn copy_backup_boundary(
518    source_root: &Path,
519    destination_root: &crate::durable_file::DurableRoot,
520    deferred_runs: &HashSet<PathBuf>,
521    copied: &mut Vec<PathBuf>,
522    control: Option<&crate::ExecutionControl>,
523) -> Result<()> {
524    let mut visited = 0;
525    crate::durable_file::walk_regular_files_nofollow(
526        source_root,
527        |relative, is_directory| {
528            if visited % 256 == 0 {
529                if let Some(control) = control {
530                    control.checkpoint()?;
531                }
532            }
533            visited += 1;
534            if backup_path_excluded(relative) {
535                return Ok(false);
536            }
537            if is_directory {
538                return Ok(true);
539            }
540            if deferred_runs.contains(relative) {
541                return Ok(false);
542            }
543            Ok(!(relative
544                .parent()
545                .and_then(Path::file_name)
546                .is_some_and(|parent| parent == "_runs")
547                && relative
548                    .extension()
549                    .is_some_and(|extension| extension == "sr")))
550        },
551        |relative| {
552            destination_root.create_directory_all(relative)?;
553            Ok(())
554        },
555        |relative, source| {
556            destination_root.copy_new_from(relative, source)?;
557            copied.push(relative.to_path_buf());
558            Ok(())
559        },
560    )
561}
562
563fn backup_path_excluded(relative: &Path) -> bool {
564    relative == Path::new("_meta/.lock")
565        || relative == Path::new("_meta/replica")
566        || relative == Path::new("_meta/repl_epoch")
567        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
568        || relative.components().any(|component| {
569            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
570        })
571}
572
573#[derive(Debug, Clone)]
574pub enum ExternalTriggerWrite {
575    Insert {
576        table: String,
577        cells: Vec<(u16, Value)>,
578    },
579    UpdateByPk {
580        table: String,
581        pk: Value,
582        cells: Vec<(u16, Value)>,
583    },
584    DeleteByPk {
585        table: String,
586        pk: Value,
587    },
588}
589
590impl ExternalTriggerWrite {
591    fn table(&self) -> &str {
592        match self {
593            Self::Insert { table, .. }
594            | Self::UpdateByPk { table, .. }
595            | Self::DeleteByPk { table, .. } => table,
596        }
597    }
598}
599
600#[derive(Debug, Clone, PartialEq)]
601pub enum ExternalTriggerBaseWrite {
602    Put {
603        table: String,
604        cells: Vec<(u16, Value)>,
605    },
606    Delete {
607        table: String,
608        row_id: RowId,
609    },
610}
611
612#[derive(Debug, Clone, PartialEq)]
613pub struct ExternalTriggerWriteResult {
614    pub state: Vec<u8>,
615    pub base_writes: Vec<ExternalTriggerBaseWrite>,
616}
617
618impl ExternalTriggerWriteResult {
619    pub fn new(state: Vec<u8>) -> Self {
620        Self {
621            state,
622            base_writes: Vec::new(),
623        }
624    }
625}
626
627pub trait ExternalTriggerBridge: Send + Sync {
628    fn apply_trigger_external_write(
629        &self,
630        entry: &ExternalTableEntry,
631        base_state: Vec<u8>,
632        op: ExternalTriggerWrite,
633    ) -> Result<ExternalTriggerWriteResult>;
634}
635
636/// A pending uniform-epoch run written during a large transaction (spec §8.5).
637struct SpilledRun {
638    table_id: u64,
639    run_id: u128,
640    pending_path: PathBuf,
641    final_path: PathBuf,
642    rows: Vec<crate::memtable::Row>,
643    row_count: u64,
644    min_rid: u64,
645    max_rid: u64,
646    content_hash: [u8; 32],
647}
648
649const SPILLED_WAL_PAYLOAD_MAX_BYTES: usize = 24 * 1024 * 1024;
650const SPILLED_WAL_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
651
652fn encode_spilled_row_chunks(
653    rows: &[crate::memtable::Row],
654    total_bytes: &mut usize,
655    total_limit: usize,
656    control: Option<&crate::ExecutionControl>,
657) -> Result<Vec<Vec<u8>>> {
658    let mut output = Vec::new();
659    let mut start = 0;
660    while start < rows.len() {
661        // Bincode's sequence length prefix is a u64 with the workspace's
662        // fixed-int options. `serialized_size` computes exact row sizes
663        // without first allocating one transaction-sized buffer.
664        let mut estimated_bytes = std::mem::size_of::<u64>();
665        let mut end = start;
666        while end < rows.len() {
667            if end % 256 == 0 {
668                if let Some(control) = control {
669                    control.checkpoint()?;
670                }
671            }
672            let row_bytes =
673                usize::try_from(bincode::serialized_size(&rows[end])?).map_err(|_| {
674                    MongrelError::ResourceLimitExceeded {
675                        resource: "spilled WAL row bytes",
676                        requested: usize::MAX,
677                        limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
678                    }
679                })?;
680            let next_bytes = estimated_bytes.checked_add(row_bytes).ok_or(
681                MongrelError::ResourceLimitExceeded {
682                    resource: "spilled WAL row bytes",
683                    requested: usize::MAX,
684                    limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
685                },
686            )?;
687            if next_bytes > SPILLED_WAL_PAYLOAD_MAX_BYTES {
688                break;
689            }
690            estimated_bytes = next_bytes;
691            end += 1;
692        }
693        if end == start {
694            return Err(MongrelError::ResourceLimitExceeded {
695                resource: "spilled WAL row bytes",
696                requested: estimated_bytes.saturating_add(1),
697                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
698            });
699        }
700        let payload = bincode::serialize(&rows[start..end])?;
701        if payload.len() > SPILLED_WAL_PAYLOAD_MAX_BYTES {
702            return Err(MongrelError::ResourceLimitExceeded {
703                resource: "spilled WAL row bytes",
704                requested: payload.len(),
705                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
706            });
707        }
708        let requested = total_bytes.checked_add(payload.len()).unwrap_or(usize::MAX);
709        if requested > total_limit {
710            return Err(MongrelError::ResourceLimitExceeded {
711                resource: "spilled WAL transaction bytes",
712                requested,
713                limit: total_limit,
714            });
715        }
716        *total_bytes = requested;
717        output.push(payload);
718        start = end;
719    }
720    Ok(output)
721}
722
723#[cfg(test)]
724mod spilled_wal_encoding_tests {
725    use super::*;
726
727    #[test]
728    fn logical_spill_payload_has_a_total_bound() {
729        let rows = (0..4)
730            .map(|row_id| crate::memtable::Row {
731                row_id: crate::rowid::RowId(row_id),
732                committed_epoch: Epoch::ZERO,
733                columns: [(1, Value::Bytes(vec![0; 64]))].into_iter().collect(),
734                deleted: false,
735            })
736            .collect::<Vec<_>>();
737        let mut total = 0;
738        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
739        assert!(matches!(
740            error,
741            MongrelError::ResourceLimitExceeded {
742                resource: "spilled WAL transaction bytes",
743                ..
744            }
745        ));
746    }
747}
748
749/// Move spill files to their final names before the WAL commit. Dropping this
750/// guard restores pending names while commit is still known not to have begun.
751/// It is disarmed immediately before the first WAL append, where the outcome
752/// can become ambiguous and recovery may need the final names.
753struct PreparedRunLinks {
754    links: Vec<(PathBuf, PathBuf)>,
755    armed: bool,
756}
757
758impl PreparedRunLinks {
759    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
760        let mut guard = Self {
761            links: Vec::with_capacity(spilled.len()),
762            armed: true,
763        };
764        for run in spilled {
765            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
766            guard
767                .links
768                .push((run.pending_path.clone(), run.final_path.clone()));
769        }
770        Ok(guard)
771    }
772
773    fn disarm(&mut self) {
774        self.armed = false;
775        for (pending, _) in &self.links {
776            if let Some(parent) = pending.parent() {
777                let _ = std::fs::remove_dir_all(parent);
778            }
779        }
780    }
781}
782
783impl Drop for PreparedRunLinks {
784    fn drop(&mut self) {
785        if !self.armed {
786            return;
787        }
788        for (pending, final_path) in self.links.iter().rev() {
789            let _ = std::fs::rename(final_path, pending);
790        }
791    }
792}
793
794struct TableApplyBatch {
795    table_id: u64,
796    handle: TableHandle,
797    ops: Vec<crate::txn::StagedOp>,
798}
799
800#[derive(Debug, Clone)]
801struct TriggerRowImage {
802    columns: HashMap<u16, Value>,
803}
804
805impl TriggerRowImage {
806    fn from_row(row: crate::memtable::Row) -> Self {
807        Self {
808            columns: row.columns,
809        }
810    }
811
812    fn from_cells(cells: &[(u16, Value)]) -> Self {
813        Self {
814            columns: cells.iter().cloned().collect(),
815        }
816    }
817}
818
819#[derive(Debug, Clone)]
820struct WriteEvent {
821    table: String,
822    kind: TriggerEvent,
823    old: Option<TriggerRowImage>,
824    new: Option<TriggerRowImage>,
825    changed_columns: Vec<u16>,
826    op_indices: Vec<usize>,
827    put_idx: Option<usize>,
828    trigger_stack: Vec<String>,
829}
830
831#[derive(Default)]
832struct TriggerExpansion {
833    before: Vec<(u64, crate::txn::Staged)>,
834    before_stacks: Vec<Vec<String>>,
835    before_external: Vec<ExternalTriggerWrite>,
836    after: Vec<(u64, crate::txn::Staged)>,
837    after_stacks: Vec<Vec<String>>,
838    after_external: Vec<ExternalTriggerWrite>,
839    ignored_indices: std::collections::BTreeSet<usize>,
840}
841
842#[derive(Clone, PartialEq)]
843struct TriggerCatalogBinding {
844    triggers: Vec<TriggerEntry>,
845    tables: Vec<(String, u64, u64)>,
846    external_tables: Vec<(String, u64, u64)>,
847}
848
849fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
850    let mut triggers = catalog
851        .triggers
852        .iter()
853        .filter(|entry| entry.trigger.enabled)
854        .cloned()
855        .collect::<Vec<_>>();
856    if triggers.is_empty() {
857        return None;
858    }
859    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
860    let mut tables = catalog
861        .tables
862        .iter()
863        .filter(|entry| matches!(entry.state, TableState::Live))
864        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
865        .collect::<Vec<_>>();
866    tables.sort_unstable();
867    let mut external_tables = catalog
868        .external_tables
869        .iter()
870        .map(|entry| {
871            (
872                entry.name.clone(),
873                entry.created_epoch,
874                entry.declared_schema.schema_id,
875            )
876        })
877        .collect::<Vec<_>>();
878    external_tables.sort_unstable();
879    Some(TriggerCatalogBinding {
880        triggers,
881        tables,
882        external_tables,
883    })
884}
885
886struct TriggerProgramOutput<'a> {
887    added: &'a mut Vec<(u64, crate::txn::Staged)>,
888    added_stacks: &'a mut Vec<Vec<String>>,
889    added_external: &'a mut Vec<ExternalTriggerWrite>,
890    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
891}
892
893#[derive(Debug, Clone, Copy, PartialEq, Eq)]
894enum TriggerProgramOutcome {
895    Continue,
896    Ignore,
897}
898
899/// An integrity issue found by [`Database::check`] (spec §16).
900#[derive(Debug, Clone)]
901pub struct CheckIssue {
902    pub table_id: u64,
903    pub table_name: String,
904    pub severity: String,
905    pub description: String,
906}
907
908/// One optimistic authorization snapshot for a complete scored read.
909#[derive(Debug, Clone)]
910pub struct AuthorizedReadSnapshot {
911    pub table: String,
912    pub table_snapshot: Snapshot,
913    pub data_generation: u64,
914    pub security_version: u64,
915    pub allowed_row_ids: Option<HashSet<RowId>>,
916}
917
918/// Exact table/security generation used by one successful authorized read.
919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
920pub struct AuthorizedReadStamp {
921    pub table_id: u64,
922    pub schema_id: u64,
923    pub data_generation: u64,
924    pub security_version: u64,
925    pub snapshot: Snapshot,
926}
927
928type RlsCacheKey = (String, u64, u64, String);
929
930/// Runtime statistics for the byte-bounded RLS candidate cache.
931#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
932pub struct RlsCacheStats {
933    pub entries: usize,
934    pub bytes: usize,
935    pub hits: u64,
936    pub misses: u64,
937    pub evictions: u64,
938    pub build_nanos: u64,
939    pub rows_evaluated: u64,
940}
941
942const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
943const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
944const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
945const CDC_MAX_EVENTS: usize = 100_000;
946const CDC_MAX_ROWS: usize = 1_000_000;
947const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
948const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
949
950fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
951    let requested = total.saturating_add(amount);
952    if requested > CDC_MAX_RETAINED_BYTES {
953        return Err(MongrelError::ResourceLimitExceeded {
954            resource,
955            requested,
956            limit: CDC_MAX_RETAINED_BYTES,
957        });
958    }
959    *total = requested;
960    Ok(())
961}
962
963fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
964    usize::try_from(row.estimated_bytes())
965        .unwrap_or(usize::MAX)
966        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
967}
968
969fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
970    let value_slot = std::mem::size_of::<serde_json::Value>();
971    row.columns.values().fold(512_usize, |bytes, value| {
972        let values = match value {
973            Value::Bytes(values) => values.len(),
974            Value::Json(values) => values.len(),
975            Value::Embedding(values) => values.len(),
976            _ => 1,
977        };
978        bytes.saturating_add(values.saturating_mul(value_slot))
979    })
980}
981
982fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
983    rows.iter().fold(0_usize, |bytes, row| {
984        bytes.saturating_add(cdc_row_json_bytes(row))
985    })
986}
987
988#[derive(Default)]
989struct RlsCache {
990    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
991    lru: VecDeque<RlsCacheKey>,
992    bytes: usize,
993    hits: u64,
994    misses: u64,
995    evictions: u64,
996    build_nanos: u64,
997    rows_evaluated: u64,
998}
999
1000impl RlsCache {
1001    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
1002        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
1003        if value.is_some() {
1004            self.hits = self.hits.saturating_add(1);
1005            self.touch(key);
1006        } else {
1007            self.misses = self.misses.saturating_add(1);
1008        }
1009        value
1010    }
1011
1012    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
1013        let bytes = key
1014            .0
1015            .len()
1016            .saturating_add(key.3.len())
1017            .saturating_add(
1018                value
1019                    .capacity()
1020                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
1021            )
1022            .saturating_add(std::mem::size_of::<RlsCacheKey>());
1023        if bytes > RLS_CACHE_MAX_BYTES {
1024            return;
1025        }
1026        if let Some((_, old_bytes)) = self.entries.remove(&key) {
1027            self.bytes = self.bytes.saturating_sub(old_bytes);
1028        }
1029        self.lru.retain(|candidate| candidate != &key);
1030        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
1031            let Some(oldest) = self.lru.pop_front() else {
1032                break;
1033            };
1034            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
1035                self.bytes = self.bytes.saturating_sub(old_bytes);
1036                self.evictions = self.evictions.saturating_add(1);
1037            }
1038        }
1039        self.bytes = self.bytes.saturating_add(bytes);
1040        self.lru.push_back(key.clone());
1041        self.entries.insert(key, (value, bytes));
1042    }
1043
1044    fn touch(&mut self, key: &RlsCacheKey) {
1045        self.lru.retain(|candidate| candidate != key);
1046        self.lru.push_back(key.clone());
1047    }
1048
1049    fn stats(&self) -> RlsCacheStats {
1050        RlsCacheStats {
1051            entries: self.entries.len(),
1052            bytes: self.bytes,
1053            hits: self.hits,
1054            misses: self.misses,
1055            evictions: self.evictions,
1056            build_nanos: self.build_nanos,
1057            rows_evaluated: self.rows_evaluated,
1058        }
1059    }
1060}
1061
1062/// Mounted table with immutable, structurally shared scored-read generations.
1063#[derive(Clone)]
1064pub struct TableHandle {
1065    inner: TableHandleInner,
1066    generation_metrics: Arc<TableGenerationMetrics>,
1067}
1068
1069#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1070pub struct TableGenerationStats {
1071    pub active_read_generations: usize,
1072    pub max_live_read_generations: usize,
1073    pub cow_clone_count: u64,
1074    pub cow_clone_nanos: u64,
1075    pub estimated_cow_clone_bytes: u64,
1076    pub writer_wait_nanos: u64,
1077}
1078
1079#[derive(Default)]
1080#[doc(hidden)]
1081pub struct TableGenerationMetrics {
1082    active_read_generations: AtomicUsize,
1083    max_live_read_generations: AtomicUsize,
1084    cow_clone_count: AtomicU64,
1085    cow_clone_nanos: AtomicU64,
1086    estimated_cow_clone_bytes: AtomicU64,
1087    writer_wait_nanos: AtomicU64,
1088}
1089
1090impl TableGenerationMetrics {
1091    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
1092        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
1093        self.max_live_read_generations
1094            .fetch_max(active, Ordering::Relaxed);
1095        Arc::new(TableReadGeneration {
1096            table,
1097            metrics: Arc::clone(self),
1098        })
1099    }
1100
1101    fn stats(&self) -> TableGenerationStats {
1102        TableGenerationStats {
1103            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
1104            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
1105            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
1106            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
1107            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
1108            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
1109        }
1110    }
1111}
1112
1113/// Immutable, structurally shared snapshot used by scored readers.
1114pub struct TableReadGeneration {
1115    table: Table,
1116    metrics: Arc<TableGenerationMetrics>,
1117}
1118
1119impl std::ops::Deref for TableReadGeneration {
1120    type Target = Table;
1121
1122    fn deref(&self) -> &Self::Target {
1123        &self.table
1124    }
1125}
1126
1127impl Drop for TableReadGeneration {
1128    fn drop(&mut self) {
1129        self.metrics
1130            .active_read_generations
1131            .fetch_sub(1, Ordering::Relaxed);
1132    }
1133}
1134
1135#[derive(Clone)]
1136enum TableHandleInner {
1137    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
1138    Direct(Arc<Mutex<Table>>),
1139}
1140
1141pub enum TableGuard<'a> {
1142    CopyOnWrite {
1143        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
1144        metrics: Arc<TableGenerationMetrics>,
1145    },
1146    Direct {
1147        table: parking_lot::MutexGuard<'a, Table>,
1148    },
1149}
1150
1151impl TableHandle {
1152    fn new(table: Table) -> Self {
1153        Self {
1154            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
1155            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1156        }
1157    }
1158
1159    pub fn from_table(table: Table) -> Self {
1160        Self::new(table)
1161    }
1162
1163    pub fn lock(&self) -> TableGuard<'_> {
1164        let started = std::time::Instant::now();
1165        let guard = match &self.inner {
1166            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
1167                table: table.write(),
1168                metrics: Arc::clone(&self.generation_metrics),
1169            },
1170            TableHandleInner::Direct(table) => TableGuard::Direct {
1171                table: table.lock(),
1172            },
1173        };
1174        self.generation_metrics.writer_wait_nanos.fetch_add(
1175            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1176            Ordering::Relaxed,
1177        );
1178        guard
1179    }
1180
1181    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
1182        let started = std::time::Instant::now();
1183        let guard = match &self.inner {
1184            TableHandleInner::CopyOnWrite(table) => {
1185                table
1186                    .try_write_for(timeout)
1187                    .map(|table| TableGuard::CopyOnWrite {
1188                        table,
1189                        metrics: Arc::clone(&self.generation_metrics),
1190                    })
1191            }
1192            TableHandleInner::Direct(table) => table
1193                .try_lock_for(timeout)
1194                .map(|table| TableGuard::Direct { table }),
1195        };
1196        self.generation_metrics.writer_wait_nanos.fetch_add(
1197            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1198            Ordering::Relaxed,
1199        );
1200        guard
1201    }
1202
1203    pub fn ptr_eq(&self, other: &Self) -> bool {
1204        match (&self.inner, &other.inner) {
1205            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1206                Arc::ptr_eq(left, right)
1207            }
1208            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1209                Arc::ptr_eq(left, right)
1210            }
1211            _ => false,
1212        }
1213    }
1214
1215    pub fn read_generation_with_context(
1216        &self,
1217        context: Option<&crate::query::AiExecutionContext>,
1218    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1219        let mut table = if let Some(context) = context {
1220            loop {
1221                context.checkpoint()?;
1222                let wait = context
1223                    .remaining_duration()
1224                    .unwrap_or(std::time::Duration::from_millis(5))
1225                    .min(std::time::Duration::from_millis(5));
1226                if let Some(table) = self.try_lock_for(wait) {
1227                    break table;
1228                }
1229            }
1230        } else {
1231            self.lock()
1232        };
1233        let snapshot = table.snapshot();
1234        let generation = table.clone_read_generation()?;
1235        Ok((self.generation_metrics.activate(generation), snapshot))
1236    }
1237
1238    pub fn generation_stats(&self) -> TableGenerationStats {
1239        self.generation_metrics.stats()
1240    }
1241}
1242
1243impl From<Arc<Mutex<Table>>> for TableHandle {
1244    fn from(table: Arc<Mutex<Table>>) -> Self {
1245        Self {
1246            inner: TableHandleInner::Direct(table),
1247            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1248        }
1249    }
1250}
1251
1252impl std::ops::Deref for TableGuard<'_> {
1253    type Target = Table;
1254
1255    fn deref(&self) -> &Self::Target {
1256        match self {
1257            Self::CopyOnWrite { table, .. } => table.as_ref(),
1258            Self::Direct { table } => table,
1259        }
1260    }
1261}
1262
1263impl std::ops::DerefMut for TableGuard<'_> {
1264    fn deref_mut(&mut self) -> &mut Self::Target {
1265        match self {
1266            Self::CopyOnWrite { table, metrics } => {
1267                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1268                    let estimated_bytes = table.estimated_clone_bytes();
1269                    let started = std::time::Instant::now();
1270                    let table = Arc::make_mut(table);
1271                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1272                    metrics.cow_clone_nanos.fetch_add(
1273                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1274                        Ordering::Relaxed,
1275                    );
1276                    metrics
1277                        .estimated_cow_clone_bytes
1278                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1279                    table
1280                } else {
1281                    Arc::make_mut(table)
1282                }
1283            }
1284            Self::Direct { table } => table,
1285        }
1286    }
1287}
1288
1289#[derive(Clone, Debug)]
1290pub struct ReadAuthorization {
1291    pub operation: crate::auth::ColumnOperation,
1292    pub columns: Vec<u16>,
1293    pub permissions: Vec<crate::auth::Permission>,
1294}
1295
1296#[derive(Default, Debug)]
1297struct TableWritePermissionNeeds {
1298    insert: bool,
1299    insert_columns: Vec<u16>,
1300    update: bool,
1301    update_columns: Vec<u16>,
1302    delete: bool,
1303    truncate: bool,
1304}
1305
1306#[cfg(test)]
1307thread_local! {
1308    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1309    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1310    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1311    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1312    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1313    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1314}
1315
1316fn summarize_write_permissions(
1317    staging: &[(u64, crate::txn::Staged)],
1318) -> HashMap<u64, TableWritePermissionNeeds> {
1319    use crate::txn::Staged;
1320
1321    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1322    for (table_id, operation) in staging {
1323        let table = needs.entry(*table_id).or_default();
1324        match operation {
1325            Staged::Put(cells) => {
1326                table.insert = true;
1327                table
1328                    .insert_columns
1329                    .extend(cells.iter().map(|(column, _)| *column));
1330            }
1331            Staged::Update {
1332                changed_columns, ..
1333            } => {
1334                table.update = true;
1335                table.update_columns.extend(changed_columns);
1336            }
1337            Staged::Delete(_) => table.delete = true,
1338            Staged::Truncate => table.truncate = true,
1339        }
1340    }
1341    for table in needs.values_mut() {
1342        table.insert_columns.sort_unstable();
1343        table.insert_columns.dedup();
1344        table.update_columns.sort_unstable();
1345        table.update_columns.dedup();
1346    }
1347    needs
1348}
1349
1350struct SecurityCoordinator {
1351    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1352    gate: RwLock<()>,
1353    version: AtomicU64,
1354}
1355
1356fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1357    static COORDINATORS: std::sync::OnceLock<
1358        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1359    > = std::sync::OnceLock::new();
1360
1361    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1362    let mut coordinators = COORDINATORS
1363        .get_or_init(|| Mutex::new(HashMap::new()))
1364        .lock();
1365    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1366    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1367        return coordinator;
1368    }
1369    let coordinator = Arc::new(SecurityCoordinator {
1370        gate: RwLock::new(()),
1371        version: AtomicU64::new(version),
1372    });
1373    coordinators.insert(root, Arc::downgrade(&coordinator));
1374    coordinator
1375}
1376
1377pub fn lock_table_with_context<'a>(
1378    handle: &'a TableHandle,
1379    context: Option<&crate::query::AiExecutionContext>,
1380) -> Result<TableGuard<'a>> {
1381    let Some(context) = context else {
1382        return Ok(handle.lock());
1383    };
1384    loop {
1385        context.checkpoint()?;
1386        let wait = context
1387            .remaining_duration()
1388            .unwrap_or(std::time::Duration::from_millis(5))
1389            .min(std::time::Duration::from_millis(5));
1390        if let Some(guard) = handle.try_lock_for(wait) {
1391            return Ok(guard);
1392        }
1393    }
1394}
1395
1396/// Knobs for [`Database::open_with_options`].
1397///
1398/// All fields default to the same values the convenience
1399/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1400/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1401#[derive(Clone, Debug, Default)]
1402pub struct OpenOptions {
1403    /// Maximum time, in milliseconds, to wait for the cross-process database
1404    /// lock (`_meta/.lock`) before failing with `MongrelError::DatabaseLocked`.
1405    ///
1406    /// `0` (the default) preserves the historical fail-fast semantics: a
1407    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1408    /// `busy_timeout` semantics kick in once this is non-zero — the open
1409    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1410    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1411    /// point the open returns the same typed lock error as the fail-fast path.
1412    ///
1413    /// Only the cross-process lock is affected. Mounted tables, page-cache
1414    /// misses, and WAL appends already serialize through in-process locks
1415    /// that handle their own contention. A second independent open in the
1416    /// same process always returns `DatabaseLocked` immediately; share the
1417    /// existing `Arc<Database>` instead.
1418    pub lock_timeout_ms: u32,
1419}
1420
1421impl OpenOptions {
1422    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1423    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1424    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1425        self.lock_timeout_ms = ms;
1426        self
1427    }
1428}
1429
1430/// A multi-table database: one catalog, one epoch clock, shared caches, a
1431/// shared WAL, and a live map of name → `Arc<Table>`.
1432pub struct Database {
1433    root: PathBuf,
1434    durable_root: Arc<crate::durable_file::DurableRoot>,
1435    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1436    read_only: bool,
1437    catalog: RwLock<Catalog>,
1438    security_coordinator: Arc<SecurityCoordinator>,
1439    security_catalog_disk_reads: AtomicU64,
1440    rls_cache: Mutex<RlsCache>,
1441    epoch: Arc<EpochAuthority>,
1442    snapshots: Arc<SnapshotRegistry>,
1443    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1444    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1445    commit_lock: Arc<Mutex<()>>,
1446    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1447    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1448    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1449    /// writes also land in this one WAL (B1 — one WAL per database).
1450    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1451    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1452    /// in P2.7; here it just needs to be unique within an open. Shared with
1453    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1454    next_txn_id: Arc<Mutex<u64>>,
1455    tables: RwLock<HashMap<u64, TableHandle>>,
1456    kek: Option<Arc<crate::encryption::Kek>>,
1457    /// Serializes DDL (create/drop table); data commits serialize through
1458    /// `commit_lock` shared via `SharedCtx`.
1459    ddl_lock: Mutex<()>,
1460    meta_dek: Option<[u8; META_DEK_LEN]>,
1461    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1462    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1463    spill_threshold: std::sync::atomic::AtomicU64,
1464    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1465    /// detection (spec §9.2).
1466    conflicts: crate::txn::ConflictIndex,
1467    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1468    /// pruning (spec §9.2, review fix #12).
1469    active_txns: crate::txn::ActiveTxns,
1470    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
1471    /// Shared with mounted tables so a single-table commit also honors poison.
1472    poisoned: Arc<std::sync::atomic::AtomicBool>,
1473    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
1474    /// but defers the fsync to one leader here, so concurrent commits share a
1475    /// single fsync (spec §9.3). Shared with mounted tables.
1476    group: Arc<crate::txn::GroupCommit>,
1477    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
1478    /// live spill's pending dir (review fix #14, spec §6.4).
1479    active_spills: Arc<crate::retention::ActiveSpills>,
1480    /// A write lock captures a consistent bootstrap image; transaction commits
1481    /// hold a read lock across spill preparation, WAL append, and publish.
1482    replication_barrier: parking_lot::RwLock<()>,
1483    /// Number of rotated WAL segments retained for lagging followers.
1484    replication_wal_retention_segments: AtomicUsize,
1485    /// Live immutable run files used by online backups or scored read
1486    /// generations. GC cannot unlink them until every owning guard drops.
1487    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1488    /// Test-only barrier invoked after a transaction writes its spill runs but
1489    /// before the sequencer/publish, so tests can race `gc()` against an
1490    /// in-flight spill. `None` in production.
1491    #[doc(hidden)]
1492    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1493    /// Test seam after the security read gate is held and before WAL append.
1494    #[doc(hidden)]
1495    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1496    /// Test seam after transaction preparation and before catalog generation
1497    /// validation under the commit sequencer.
1498    #[doc(hidden)]
1499    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1500    /// Test seam after a backup boundary is captured and before pinned runs are
1501    /// copied. Lets tests compact+GC the source at the worst possible moment.
1502    #[doc(hidden)]
1503    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1504    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1505    trigger_recursive: AtomicBool,
1506    trigger_max_depth: AtomicU32,
1507    trigger_max_loop_iterations: AtomicU32,
1508    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
1509    /// is reconstructed from the WAL by [`Database::change_events_since`].
1510    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
1511    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
1512    /// from the WAL, so lagged receivers lose only a wake-up, never data.
1513    change_wake: tokio::sync::broadcast::Sender<()>,
1514    /// The authenticated principal for this handle. `None` on databases
1515    /// opened without credentials (the default — `require_auth = false`),
1516    /// `Some` on credentialed opens. Consulted by every enforcement point
1517    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
1518    /// because the access pattern is read-heavy: every `require()` call
1519    /// reads the principal, while writes happen only at open, `enable_auth`,
1520    /// and `refresh_principal`. This matches the engine's existing use of
1521    /// `RwLock` for `catalog` and `tables`.
1522    /// See `docs/15-credential-enforcement.md`.
1523    principal: RwLock<Option<crate::auth::Principal>>,
1524    /// Shared, cloneable handle to the auth state (require_auth flag from the
1525    /// catalog + the principal). Cloned into every mounted `Table` so the
1526    /// Table layer can enforce permissions without holding a reference back
1527    /// to `Database` (which would create a cycle). `AuthState` is already
1528    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
1529    auth_state: crate::auth_state::AuthState,
1530    /// Final field so every storage resource drops before the exclusive lease.
1531    _lock: Option<ExclusiveDatabaseLease>,
1532}
1533
1534struct RunPins {
1535    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1536    runs: Vec<(u64, u128)>,
1537}
1538
1539struct BackupFilePins {
1540    root: PathBuf,
1541}
1542
1543struct PendingTableDir {
1544    path: PathBuf,
1545    armed: bool,
1546}
1547
1548impl PendingTableDir {
1549    fn new(path: PathBuf) -> Self {
1550        Self { path, armed: true }
1551    }
1552
1553    fn disarm(&mut self) {
1554        self.armed = false;
1555    }
1556}
1557
1558impl Drop for PendingTableDir {
1559    fn drop(&mut self) {
1560        if self.armed {
1561            let _ = std::fs::remove_dir_all(&self.path);
1562        }
1563    }
1564}
1565
1566impl Drop for BackupFilePins {
1567    fn drop(&mut self) {
1568        let _ = std::fs::remove_dir_all(&self.root);
1569    }
1570}
1571
1572impl Drop for RunPins {
1573    fn drop(&mut self) {
1574        let mut pins = self.pins.lock();
1575        for run in &self.runs {
1576            if let Some(count) = pins.get_mut(run) {
1577                *count -= 1;
1578                if *count == 0 {
1579                    pins.remove(run);
1580                }
1581            }
1582        }
1583    }
1584}
1585
1586/// A durable data-change event reconstructed from committed WAL records, or an
1587/// ephemeral SQL `NOTIFY` event when `id` is `None`.
1588#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1589pub struct ChangeEvent {
1590    pub id: Option<String>,
1591    pub channel: String,
1592    pub table_id: Option<u64>,
1593    pub table: String,
1594    pub op: String,
1595    pub epoch: u64,
1596    pub txn_id: Option<u64>,
1597    pub message: Option<String>,
1598    pub data: Option<serde_json::Value>,
1599}
1600
1601#[derive(Debug, Clone)]
1602pub struct CdcBatch {
1603    pub events: Vec<ChangeEvent>,
1604    pub current_epoch: u64,
1605    pub earliest_epoch: Option<u64>,
1606    pub gap: bool,
1607}
1608
1609/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
1610/// (root, epoch, table count, encryption/auth state) without requiring every
1611/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
1612/// The raw field types carry locks, trait objects, and channels that have no
1613/// useful `Debug` output, so a hand-written impl is clearer than peppering
1614/// `#[allow(dead_code)]` skip attributes across two dozen fields.
1615impl std::fmt::Debug for Database {
1616    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1617        let cat = self.catalog.read();
1618        let principal_guard = self.principal.read();
1619        let principal: &str = principal_guard
1620            .as_ref()
1621            .map(|p| p.username.as_str())
1622            .unwrap_or("<none>");
1623        f.debug_struct("Database")
1624            .field("root", &self.root)
1625            .field("db_epoch", &cat.db_epoch)
1626            .field("open_generation", &"sidecar")
1627            .field("tables", &cat.tables.len())
1628            .field("visible_epoch", &self.epoch.visible().0)
1629            .field("encrypted", &self.kek.is_some())
1630            .field("require_auth", &cat.require_auth)
1631            .field("principal", &principal)
1632            .finish()
1633    }
1634}
1635
1636impl Database {
1637    pub fn open_metrics() -> DatabaseOpenMetrics {
1638        DatabaseOpenMetrics {
1639            lock_waits: DATABASE_OPEN_WAIT_COUNT.load(Ordering::Relaxed),
1640            failures: DATABASE_OPEN_FAILURE_COUNT.load(Ordering::Relaxed),
1641        }
1642    }
1643
1644    fn ensure_owner_process(&self) -> Result<()> {
1645        let current_pid = std::process::id();
1646        let owner_pid = self
1647            ._lock
1648            .as_ref()
1649            .map(|lease| lease.owner_pid)
1650            .unwrap_or(current_pid);
1651        if current_pid == owner_pid {
1652            Ok(())
1653        } else {
1654            Err(MongrelError::ForkedProcess {
1655                owner_pid,
1656                current_pid,
1657            })
1658        }
1659    }
1660
1661    /// Explicitly close the final shared database owner.
1662    pub fn shutdown(self: Arc<Self>) -> Result<()> {
1663        match Arc::try_unwrap(self) {
1664            Ok(database) => {
1665                database.ensure_owner_process()?;
1666                drop(database);
1667                Ok(())
1668            }
1669            Err(database) => Err(MongrelError::DatabaseBusy {
1670                strong_handles: Arc::strong_count(&database),
1671            }),
1672        }
1673    }
1674
1675    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
1676        if let Ok(canonical) = root.canonicalize() {
1677            let lock_dir = canonical.parent().ok_or_else(|| {
1678                std::io::Error::new(
1679                    std::io::ErrorKind::InvalidInput,
1680                    "database root must have a parent directory",
1681                )
1682            })?;
1683            return Ok((canonical.clone(), lock_dir.to_path_buf()));
1684        }
1685
1686        let absolute = if root.is_absolute() {
1687            root.to_path_buf()
1688        } else {
1689            std::env::current_dir()?.join(root)
1690        };
1691        let mut cursor = absolute.as_path();
1692        let mut suffix = Vec::new();
1693        while !cursor.exists() {
1694            let name = cursor.file_name().ok_or_else(|| {
1695                std::io::Error::new(
1696                    std::io::ErrorKind::NotFound,
1697                    format!("no existing ancestor for database root {}", root.display()),
1698                )
1699            })?;
1700            suffix.push(name.to_os_string());
1701            cursor = cursor.parent().ok_or_else(|| {
1702                std::io::Error::new(
1703                    std::io::ErrorKind::NotFound,
1704                    format!("no existing ancestor for database root {}", root.display()),
1705                )
1706            })?;
1707        }
1708        let lock_dir = cursor.canonicalize()?;
1709        let mut canonical = lock_dir.clone();
1710        for component in suffix.iter().rev() {
1711            canonical.push(component);
1712        }
1713        Ok((canonical, lock_dir))
1714    }
1715
1716    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<ExclusiveDatabaseLease> {
1717        use std::hash::{Hash, Hasher};
1718
1719        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
1720        let reservation =
1721            OpenReservation::acquire(DatabaseOpenKey::IntendedPath(canonical_path.clone()), root)?;
1722
1723        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1724        canonical_path.hash(&mut hasher);
1725        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
1726        let file = std::fs::OpenOptions::new()
1727            .create(true)
1728            .truncate(false)
1729            .write(true)
1730            .open(lock_path)?;
1731        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
1732            return Err(MongrelError::DatabaseLocked {
1733                path: root.to_path_buf(),
1734                message: error.to_string(),
1735            });
1736        }
1737        Ok(reservation.into_lease(file, canonical_path))
1738    }
1739
1740    fn acquire_legacy_database_lock(
1741        lock: &mut ExclusiveDatabaseLease,
1742        root: &Path,
1743        timeout_ms: u32,
1744    ) -> Result<()> {
1745        let durable_root = lock
1746            .durable_root
1747            .as_ref()
1748            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
1749        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
1750        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
1751            return Err(MongrelError::DatabaseLocked {
1752                path: root.to_path_buf(),
1753                message: error.to_string(),
1754            });
1755        }
1756        lock.legacy_file = Some(file);
1757        Ok(())
1758    }
1759
1760    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
1761        if path.exists() {
1762            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
1763        }
1764        let mut ancestor = path;
1765        while !ancestor.exists() {
1766            ancestor = ancestor.parent().ok_or_else(|| {
1767                MongrelError::NotFound(format!(
1768                    "no existing ancestor for database root {}",
1769                    path.display()
1770                ))
1771            })?;
1772        }
1773        let relative = path.strip_prefix(ancestor).map_err(|error| {
1774            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
1775        })?;
1776        crate::durable_file::DurableRoot::open(ancestor)?
1777            .create_directory_all_pinned(relative)
1778            .map_err(Into::into)
1779    }
1780
1781    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
1782        let requested_root = root.as_ref();
1783        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
1784        let root = lock.canonical_path.clone();
1785        Self::reject_existing_database(&root)?;
1786        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
1787        if durable_root.canonical_path() != lock.canonical_path {
1788            return Err(MongrelError::Conflict(
1789                "database root changed while it was being created".into(),
1790            ));
1791        }
1792        lock.claim_root_identity(&durable_root)?;
1793        durable_root.create_directory_all(META_DIR)?;
1794        lock.durable_root = Some(durable_root);
1795        let io_root = lock
1796            .durable_root
1797            .as_ref()
1798            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1799            .io_path()?;
1800        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
1801        Self::reject_existing_database(&io_root)?;
1802        Ok((io_root, lock))
1803    }
1804
1805    fn begin_open(
1806        root: impl AsRef<Path>,
1807        lock_timeout_ms: u32,
1808    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
1809        let root = root.as_ref();
1810        let canonical_root = root.canonicalize().map_err(|error| {
1811            if error.kind() == std::io::ErrorKind::NotFound {
1812                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
1813            } else {
1814                error.into()
1815            }
1816        })?;
1817        let durable_root = crate::durable_file::DurableRoot::open(&canonical_root)?;
1818        Self::begin_open_durable(durable_root, lock_timeout_ms)
1819    }
1820
1821    fn begin_open_durable(
1822        durable_root: crate::durable_file::DurableRoot,
1823        lock_timeout_ms: u32,
1824    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
1825        let io_root = durable_root.io_path()?;
1826        let current_root = io_root.canonicalize()?;
1827        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
1828        lock.claim_root_identity(&durable_root)?;
1829        lock.durable_root = Some(Arc::new(durable_root));
1830        let io_root = lock
1831            .durable_root
1832            .as_ref()
1833            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1834            .io_path()?;
1835        if lock
1836            .durable_root
1837            .as_ref()
1838            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1839            .open_directory(META_DIR)
1840            .is_err()
1841        {
1842            return Err(MongrelError::NotFound(format!(
1843                "no database metadata found at {:?}",
1844                current_root
1845            )));
1846        }
1847        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
1848        Ok((io_root, lock))
1849    }
1850
1851    /// Create a fresh plaintext database at `root`.
1852    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
1853        let (root, lock) = Self::begin_create(root)?;
1854        Self::create_inner(root, None, lock)
1855    }
1856
1857    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
1858    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
1859    #[cfg(feature = "encryption")]
1860    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1861        let (root, lock) = Self::begin_create(root)?;
1862        let salt = crate::encryption::random_salt()?;
1863        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1864        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1865        Self::create_inner(root, Some(kek), lock)
1866    }
1867
1868    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
1869    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
1870    #[cfg(feature = "encryption")]
1871    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1872        let (root, lock) = Self::begin_create(root)?;
1873        let salt = crate::encryption::random_salt()?;
1874        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1875        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1876        Self::create_inner(root, Some(kek), lock)
1877    }
1878
1879    fn create_inner(
1880        root: PathBuf,
1881        kek: Option<Arc<crate::encryption::Kek>>,
1882        lock: ExclusiveDatabaseLease,
1883    ) -> Result<Self> {
1884        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
1885        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1886        let cat = Catalog::empty();
1887        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
1888        Self::finish_open(root, cat, kek, meta_dek, false, None, None, None, lock)
1889    }
1890
1891    /// Open an existing plaintext database.
1892    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
1893        Self::open_inner(root, None, None)
1894    }
1895
1896    /// Open an existing encrypted database with a passphrase.
1897    #[cfg(feature = "encryption")]
1898    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1899        let (root, lock) = Self::begin_open(root, 0)?;
1900        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1901            MongrelError::Other("database root descriptor was not pinned".into())
1902        })?)?;
1903        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1904        Self::open_inner_locked(root, Some(kek), lock)
1905    }
1906
1907    /// Open an existing encrypted database with a configurable cross-process
1908    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
1909    #[cfg(feature = "encryption")]
1910    pub fn open_encrypted_with_options(
1911        root: impl AsRef<Path>,
1912        passphrase: &str,
1913        options: OpenOptions,
1914    ) -> Result<Self> {
1915        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
1916        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1917            MongrelError::Other("database root descriptor was not pinned".into())
1918        })?)?;
1919        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1920        Self::open_inner_locked(root, Some(kek), lock)
1921    }
1922
1923    /// Open an existing encrypted database using a raw high-entropy key.
1924    #[cfg(feature = "encryption")]
1925    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1926        let (root, lock) = Self::begin_open(root, 0)?;
1927        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1928            MongrelError::Other("database root descriptor was not pinned".into())
1929        })?)?;
1930        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1931        Self::open_inner_locked(root, Some(kek), lock)
1932    }
1933
1934    /// Open an existing plaintext database that has `require_auth = true`,
1935    /// verifying the supplied credentials up front and caching the resolved
1936    /// [`Principal`] on the returned handle. Every subsequent operation will
1937    /// be checked against that principal.
1938    ///
1939    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
1940    /// `require_auth` enabled — callers must pick the matching constructor for
1941    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
1942    /// bad username/password.
1943    ///
1944    /// See `docs/15-credential-enforcement.md`.
1945    pub fn open_with_credentials(
1946        root: impl AsRef<Path>,
1947        username: &str,
1948        password: &str,
1949    ) -> Result<Self> {
1950        Self::open_inner_with_credentials(root, None, username, password)
1951    }
1952
1953    /// Open with credentials and a configurable cross-process lock timeout.
1954    /// Mirrors [`open_with_options`](Self::open_with_options) for the
1955    /// credentialed path.
1956    pub fn open_with_credentials_and_options(
1957        root: impl AsRef<Path>,
1958        username: &str,
1959        password: &str,
1960        options: OpenOptions,
1961    ) -> Result<Self> {
1962        Self::open_inner_with_credentials_and_lock_timeout(
1963            root,
1964            None,
1965            username,
1966            password,
1967            options.lock_timeout_ms,
1968        )
1969    }
1970
1971    /// Open an existing encrypted database that has `require_auth = true`,
1972    /// combining the encryption passphrase flow with credential verification.
1973    #[cfg(feature = "encryption")]
1974    pub fn open_encrypted_with_credentials(
1975        root: impl AsRef<Path>,
1976        passphrase: &str,
1977        username: &str,
1978        password: &str,
1979    ) -> Result<Self> {
1980        let (root, lock) = Self::begin_open(root, 0)?;
1981        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1982            MongrelError::Other("database root descriptor was not pinned".into())
1983        })?)?;
1984        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1985        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1986    }
1987
1988    /// Open an encrypted + credentialed database with a configurable
1989    /// cross-process lock timeout. Mirrors
1990    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
1991    #[cfg(feature = "encryption")]
1992    pub fn open_encrypted_with_credentials_and_options(
1993        root: impl AsRef<Path>,
1994        passphrase: &str,
1995        username: &str,
1996        password: &str,
1997        options: OpenOptions,
1998    ) -> Result<Self> {
1999        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2000        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2001            MongrelError::Other("database root descriptor was not pinned".into())
2002        })?)?;
2003        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2004        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
2005    }
2006
2007    /// Open an existing database with non-default [`OpenOptions`].
2008    ///
2009    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
2010    /// rather than the fail-fast default. The other open constructors keep
2011    /// their previous defaults; use their `*_with_options` variants when they
2012    /// need the same timeout behavior.
2013    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
2014        // No encryption, no auth; encrypted and credentialed paths have their
2015        // own `*_with_options` constructors.
2016        Self::open_inner_with_lock_timeout(root, None, None, options.lock_timeout_ms)
2017    }
2018
2019    fn open_inner_with_lock_timeout(
2020        root: impl AsRef<Path>,
2021        kek: Option<Arc<crate::encryption::Kek>>,
2022        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2023        lock_timeout_ms: u32,
2024    ) -> Result<Self> {
2025        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
2026        Self::open_inner_locked(root, kek, lock)
2027    }
2028
2029    fn open_inner_locked(
2030        root: PathBuf,
2031        kek: Option<Arc<crate::encryption::Kek>>,
2032        lock: ExclusiveDatabaseLease,
2033    ) -> Result<Self> {
2034        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2035        let mut cat = catalog::read_durable(
2036            lock.durable_root.as_deref().ok_or_else(|| {
2037                MongrelError::Other("database root descriptor was not pinned".into())
2038            })?,
2039            meta_dek.as_ref(),
2040        )?
2041        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2042        let recovery_checkpoint = cat.clone();
2043
2044        // CATALOG is only a checkpoint. Authentication must use the
2045        // authoritative catalog after committed WAL DDL/security replay.
2046        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2047        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2048            lock.durable_root.as_deref().ok_or_else(|| {
2049                MongrelError::Other("database root descriptor was not pinned".into())
2050            })?,
2051            wal_dek.as_ref(),
2052        )?;
2053        recover_ddl_from_records(
2054            &root,
2055            Some(lock.durable_root.as_deref().ok_or_else(|| {
2056                MongrelError::Other("database root descriptor was not pinned".into())
2057            })?),
2058            &mut cat,
2059            meta_dek.as_ref(),
2060            false,
2061            None,
2062            &recovery_records,
2063        )?;
2064        Self::finish_open(
2065            root,
2066            cat,
2067            kek,
2068            meta_dek,
2069            true,
2070            Some(recovery_checkpoint),
2071            Some(recovery_records),
2072            None,
2073            lock,
2074        )
2075    }
2076
2077    /// Shared credentialed-open inner: read the catalog, verify the database
2078    /// requires auth, verify the password, resolve the principal, and pass
2079    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
2080    /// problem where `finish_open`'s fail-closed check (`require_auth &&
2081    /// principal.is_none()`) would fire before a post-open `authenticate()`
2082    /// could supply the principal.
2083    fn open_inner_with_credentials(
2084        root: impl AsRef<Path>,
2085        kek: Option<Arc<crate::encryption::Kek>>,
2086        username: &str,
2087        password: &str,
2088    ) -> Result<Self> {
2089        Self::open_inner_with_credentials_and_lock_timeout(root, kek, username, password, 0)
2090    }
2091
2092    /// Credentialed-open with an explicit cross-process lock timeout. The
2093    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
2094    /// historical fail-fast behavior via the wrapper above.
2095    fn open_inner_with_credentials_and_lock_timeout(
2096        root: impl AsRef<Path>,
2097        kek: Option<Arc<crate::encryption::Kek>>,
2098        username: &str,
2099        password: &str,
2100        lock_timeout_ms: u32,
2101    ) -> Result<Self> {
2102        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
2103        Self::open_inner_with_credentials_locked(root, kek, username, password, lock)
2104    }
2105
2106    fn open_inner_with_credentials_locked(
2107        root: PathBuf,
2108        kek: Option<Arc<crate::encryption::Kek>>,
2109        username: &str,
2110        password: &str,
2111        lock: ExclusiveDatabaseLease,
2112    ) -> Result<Self> {
2113        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2114        let mut cat = catalog::read_durable(
2115            lock.durable_root.as_deref().ok_or_else(|| {
2116                MongrelError::Other("database root descriptor was not pinned".into())
2117            })?,
2118            meta_dek.as_ref(),
2119        )?
2120        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2121        let recovery_checkpoint = cat.clone();
2122
2123        // Never verify against a stale checkpoint. A committed password,
2124        // user, role, or auth-mode change in WAL is authoritative.
2125        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2126        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2127            lock.durable_root.as_deref().ok_or_else(|| {
2128                MongrelError::Other("database root descriptor was not pinned".into())
2129            })?,
2130            wal_dek.as_ref(),
2131        )?;
2132        recover_ddl_from_records(
2133            &root,
2134            Some(lock.durable_root.as_deref().ok_or_else(|| {
2135                MongrelError::Other("database root descriptor was not pinned".into())
2136            })?),
2137            &mut cat,
2138            meta_dek.as_ref(),
2139            false,
2140            None,
2141            &recovery_records,
2142        )?;
2143
2144        // Fail early if the database is not in require_auth mode — the caller
2145        // picked the wrong constructor.
2146        if !cat.require_auth {
2147            return Err(MongrelError::AuthNotRequired);
2148        }
2149
2150        // Verify credentials against the on-disk catalog before constructing
2151        // the full Database handle. This reads users/hashes directly from the
2152        // loaded catalog rather than going through the Database::verify_user
2153        // method (which requires a constructed Database).
2154        let user = cat
2155            .users
2156            .iter()
2157            .find(|u| u.username == username)
2158            .filter(|u| !u.password_hash.is_empty())
2159            .ok_or_else(|| MongrelError::InvalidCredentials {
2160                username: username.to_string(),
2161            })?;
2162        let password_ok = crate::auth::verify_password(password, &user.password_hash)
2163            .map_err(MongrelError::Other)?;
2164        if !password_ok {
2165            return Err(MongrelError::InvalidCredentials {
2166                username: username.to_string(),
2167            });
2168        }
2169
2170        // Resolve the principal from the catalog (roles + permissions).
2171        let principal =
2172            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
2173                MongrelError::InvalidCredentials {
2174                    username: username.to_string(),
2175                }
2176            })?;
2177
2178        Self::finish_open(
2179            root,
2180            cat,
2181            kek,
2182            meta_dek,
2183            true,
2184            Some(recovery_checkpoint),
2185            Some(recovery_records),
2186            Some(principal),
2187            lock,
2188        )
2189    }
2190
2191    /// Create a fresh plaintext database with `require_auth = true` and a
2192    /// single admin user. The returned handle is already authenticated as
2193    /// that admin — every subsequent operation is checked against the admin
2194    /// principal (which bypasses all permission checks via `is_admin`).
2195    ///
2196    /// This is the bootstrap path: there is no window where the database
2197    /// requires auth but has no users.
2198    ///
2199    /// See `docs/15-credential-enforcement.md`.
2200    pub fn create_with_credentials(
2201        root: impl AsRef<Path>,
2202        admin_username: &str,
2203        admin_password: &str,
2204    ) -> Result<Self> {
2205        let (root, lock) = Self::begin_create(root)?;
2206        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
2207    }
2208
2209    /// Create a fresh encrypted database with `require_auth = true` and a
2210    /// single admin user. Composes encryption-at-rest with credential
2211    /// enforcement.
2212    #[cfg(feature = "encryption")]
2213    pub fn create_encrypted_with_credentials(
2214        root: impl AsRef<Path>,
2215        passphrase: &str,
2216        admin_username: &str,
2217        admin_password: &str,
2218    ) -> Result<Self> {
2219        let (root, lock) = Self::begin_create(root)?;
2220        let salt = crate::encryption::random_salt()?;
2221        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2222        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2223        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
2224    }
2225
2226    fn create_inner_with_credentials(
2227        root: PathBuf,
2228        kek: Option<Arc<crate::encryption::Kek>>,
2229        admin_username: &str,
2230        admin_password: &str,
2231        lock: ExclusiveDatabaseLease,
2232    ) -> Result<Self> {
2233        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2234        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2235
2236        // Build the initial catalog with require_auth = true and one admin user.
2237        let password_hash =
2238            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
2239        let mut cat = Catalog::empty();
2240        cat.require_auth = true;
2241        cat.next_user_id = 2;
2242        cat.users.push(crate::auth::UserEntry {
2243            id: 1,
2244            username: admin_username.to_string(),
2245            password_hash,
2246            roles: Vec::new(),
2247            is_admin: true,
2248            created_epoch: 0,
2249        });
2250        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2251
2252        // The handle is constructed already authenticated as the admin user
2253        // it just created — no separate verify step needed.
2254        let admin_principal = crate::auth::Principal {
2255            user_id: 1,
2256            created_epoch: 0,
2257            username: admin_username.to_string(),
2258            is_admin: true,
2259            roles: Vec::new(),
2260            permissions: Vec::new(),
2261        };
2262        Self::finish_open(
2263            root,
2264            cat,
2265            kek,
2266            meta_dek,
2267            false,
2268            None,
2269            None,
2270            Some(admin_principal),
2271            lock,
2272        )
2273    }
2274
2275    fn reject_existing_database(root: &Path) -> Result<()> {
2276        // Refuse to overwrite an existing database. If CATALOG exists, the
2277        // directory already contains a real database; replacing it destroys data.
2278        if root.join(catalog::CATALOG_FILENAME).exists() {
2279            return Err(MongrelError::InvalidArgument(format!(
2280                "database already exists at {}; use Database::open() to open it, \
2281                 or remove the directory first",
2282                root.display()
2283            )));
2284        }
2285        Ok(())
2286    }
2287
2288    fn open_inner(
2289        root: impl AsRef<Path>,
2290        kek: Option<Arc<crate::encryption::Kek>>,
2291        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2292    ) -> Result<Self> {
2293        Self::open_inner_with_lock_timeout(root, kek, None, 0)
2294    }
2295
2296    /// Internal recovery open for a staging directory explicitly marked as a
2297    /// read-only replica. It bypasses user authentication only so PITR can
2298    /// replay auth-mode and password transitions; it is not public API.
2299    pub(crate) fn open_replica_recovery_durable(
2300        root: &crate::durable_file::DurableRoot,
2301    ) -> Result<Self> {
2302        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2303        Self::open_replica_recovery_inner(root, None, lock)
2304    }
2305
2306    #[cfg(feature = "encryption")]
2307    pub(crate) fn open_encrypted_replica_recovery_durable(
2308        root: &crate::durable_file::DurableRoot,
2309        passphrase: &str,
2310    ) -> Result<Self> {
2311        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2312        let salt = read_encryption_salt(root)?;
2313        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2314        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
2315    }
2316
2317    fn open_replica_recovery_inner(
2318        root: PathBuf,
2319        kek: Option<Arc<crate::encryption::Kek>>,
2320        lock: ExclusiveDatabaseLease,
2321    ) -> Result<Self> {
2322        if !root.join(META_DIR).join("replica").is_file() {
2323            return Err(MongrelError::InvalidArgument(
2324                "recovery auth bypass requires a marked replica staging directory".into(),
2325            ));
2326        }
2327        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2328        let mut cat = catalog::read_durable(
2329            lock.durable_root.as_deref().ok_or_else(|| {
2330                MongrelError::Other("database root descriptor was not pinned".into())
2331            })?,
2332            meta_dek.as_ref(),
2333        )?
2334        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2335        let recovery_checkpoint = cat.clone();
2336        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2337        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2338            lock.durable_root.as_deref().ok_or_else(|| {
2339                MongrelError::Other("database root descriptor was not pinned".into())
2340            })?,
2341            wal_dek.as_ref(),
2342        )?;
2343        recover_ddl_from_records(
2344            &root,
2345            Some(lock.durable_root.as_deref().ok_or_else(|| {
2346                MongrelError::Other("database root descriptor was not pinned".into())
2347            })?),
2348            &mut cat,
2349            meta_dek.as_ref(),
2350            false,
2351            None,
2352            &recovery_records,
2353        )?;
2354        let principal = if cat.require_auth {
2355            cat.users
2356                .iter()
2357                .find(|user| user.is_admin)
2358                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
2359                .ok_or_else(|| {
2360                    MongrelError::Schema(
2361                        "authenticated replica catalog has no recoverable admin".into(),
2362                    )
2363                })?
2364                .into()
2365        } else {
2366            None
2367        };
2368        Self::finish_open(
2369            root,
2370            cat,
2371            kek,
2372            meta_dek,
2373            true,
2374            Some(recovery_checkpoint),
2375            Some(recovery_records),
2376            principal,
2377            lock,
2378        )
2379    }
2380
2381    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
2382    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
2383    ///
2384    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
2385    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
2386    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
2387    ///
2388    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
2389    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
2390    /// caller never blocks past its budget even at the tail of a busy lock
2391    /// holder's lock-window.
2392    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
2393        use fs2::FileExt;
2394        if timeout_ms == 0 {
2395            return f.try_lock_exclusive();
2396        }
2397        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
2398        let deadline =
2399            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
2400        let mut next_sleep = std::time::Duration::from_millis(1);
2401        let mut recorded_wait = false;
2402        loop {
2403            match f.try_lock_exclusive() {
2404                Ok(()) => return Ok(()),
2405                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
2406                    if !recorded_wait {
2407                        DATABASE_OPEN_WAIT_COUNT.fetch_add(1, Ordering::Relaxed);
2408                        recorded_wait = true;
2409                    }
2410                    let now = std::time::Instant::now();
2411                    if now >= deadline {
2412                        return Err(std::io::Error::new(
2413                            std::io::ErrorKind::WouldBlock,
2414                            format!("could not acquire database lock within {timeout_ms}ms"),
2415                        ));
2416                    }
2417                    let remaining = deadline - now;
2418                    let sleep = next_sleep.min(remaining);
2419                    std::thread::sleep(sleep);
2420                    // Cap the per-iteration sleep so a single back-off step
2421                    // never overshoots the remaining budget.
2422                    next_sleep = next_sleep
2423                        .saturating_mul(10)
2424                        .min(std::time::Duration::from_millis(50));
2425                }
2426                Err(e) => return Err(e),
2427            }
2428        }
2429    }
2430
2431    #[allow(clippy::too_many_arguments)]
2432    fn finish_open(
2433        root: PathBuf,
2434        cat: Catalog,
2435        kek: Option<Arc<crate::encryption::Kek>>,
2436        meta_dek: Option<[u8; META_DEK_LEN]>,
2437        existing: bool,
2438        recovery_checkpoint: Option<Catalog>,
2439        recovery_records: Option<Vec<crate::wal::Record>>,
2440        principal: Option<crate::auth::Principal>,
2441        lock: ExclusiveDatabaseLease,
2442    ) -> Result<Self> {
2443        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
2444            MongrelError::Other("database root descriptor was not pinned".into())
2445        })?);
2446        let read_only = if existing {
2447            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
2448                Ok(_) => true,
2449                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
2450                Err(error) => return Err(error.into()),
2451            }
2452        } else {
2453            false
2454        };
2455        let recovered_catalog = cat;
2456        let mut cat = recovered_catalog.clone();
2457        let abandoned = if existing && !read_only {
2458            let abandoned = cat
2459                .tables
2460                .iter()
2461                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
2462                .map(|entry| entry.table_id)
2463                .collect::<Vec<_>>();
2464            for entry in &mut cat.tables {
2465                if abandoned.contains(&entry.table_id) {
2466                    entry.state = TableState::Dropped {
2467                        at_epoch: cat.db_epoch,
2468                    };
2469                }
2470            }
2471            abandoned
2472        } else {
2473            Vec::new()
2474        };
2475        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2476        let recovery_records = match (existing, recovery_records) {
2477            (true, Some(records)) => records,
2478            (true, None) => {
2479                return Err(MongrelError::Other(
2480                    "existing open has no validated WAL recovery plan".into(),
2481                ))
2482            }
2483            (false, _) => Vec::new(),
2484        };
2485        let (history_epochs, history_start) =
2486            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
2487        let open_generation = if existing {
2488            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
2489                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2490            })?;
2491            let recovered_table_ids = cat
2492                .tables
2493                .iter()
2494                .filter(|entry| {
2495                    checkpoint
2496                        .tables
2497                        .iter()
2498                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
2499                })
2500                .map(|entry| entry.table_id)
2501                .collect::<HashSet<_>>();
2502            let reconciled_table_ids = cat
2503                .tables
2504                .iter()
2505                .filter(|entry| {
2506                    checkpoint
2507                        .tables
2508                        .iter()
2509                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
2510                        .is_some_and(|checkpoint| {
2511                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
2512                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
2513                        })
2514                })
2515                .map(|entry| entry.table_id)
2516                .collect::<HashSet<_>>();
2517            validate_shared_wal_recovery_plan(
2518                &durable_root,
2519                &cat,
2520                &recovered_table_ids,
2521                &reconciled_table_ids,
2522                meta_dek.as_ref(),
2523                kek.clone(),
2524                &recovery_records,
2525            )?;
2526            let retained_generation = recovery_records
2527                .iter()
2528                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
2529                .map(|record| record.txn_id >> 32)
2530                .max()
2531                .unwrap_or(0);
2532            let head_generation =
2533                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
2534            let durable_floor = match head_generation {
2535                Some(head) if retained_generation > head => {
2536                    return Err(MongrelError::CorruptWal {
2537                        offset: retained_generation,
2538                        reason: format!(
2539                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
2540                        ),
2541                    })
2542                }
2543                Some(head) => head,
2544                None => retained_generation,
2545            };
2546            let stored = catalog::read_generation(&durable_root)?;
2547            if stored.is_some_and(|generation| generation < durable_floor) {
2548                return Err(MongrelError::Other(format!(
2549                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
2550                )));
2551            }
2552            let bumped = stored
2553                .unwrap_or(durable_floor)
2554                .max(durable_floor)
2555                .checked_add(1)
2556                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
2557            if bumped > u32::MAX as u64 {
2558                return Err(MongrelError::Full(
2559                    "open-generation namespace exhausted".into(),
2560                ));
2561            }
2562            bumped
2563        } else {
2564            0
2565        };
2566        let principal = if cat.require_auth {
2567            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
2568            Some(
2569                Self::resolve_bound_principal_from_catalog(&cat, supplied)
2570                    .ok_or(MongrelError::AuthRequired)?,
2571            )
2572        } else {
2573            principal
2574        };
2575        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
2576        if existing {
2577            for entry in &cat.tables {
2578                if !matches!(entry.state, TableState::Live) {
2579                    continue;
2580                }
2581                match durable_root
2582                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
2583                {
2584                    Ok(root) => {
2585                        table_roots.insert(entry.table_id, Arc::new(root));
2586                    }
2587                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2588                    Err(error) => return Err(error.into()),
2589                }
2590            }
2591        }
2592
2593        // No database-tree mutation occurs above this point. DDL, row payloads,
2594        // immutable runs, auth state, retention, and generation state have all
2595        // been validated against the authoritative recovered catalog.
2596        if existing {
2597            let mut applied = recovery_checkpoint.ok_or_else(|| {
2598                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2599            })?;
2600            recover_ddl_from_records(
2601                &root,
2602                Some(&durable_root),
2603                &mut applied,
2604                meta_dek.as_ref(),
2605                true,
2606                Some(&table_roots),
2607                &recovery_records,
2608            )?;
2609            let catalog_value = |catalog: &Catalog| {
2610                serde_json::to_value(catalog)
2611                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
2612            };
2613            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
2614                return Err(MongrelError::CorruptWal {
2615                    offset: 0,
2616                    reason: "validated and applied DDL recovery plans differ".into(),
2617                });
2618            }
2619            if catalog_value(&cat)? != catalog_value(&applied)? {
2620                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2621            }
2622            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
2623            if !read_only {
2624                sweep_unreferenced_table_dirs(&root, &cat)?;
2625            }
2626            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
2627                Ok(()) => {}
2628                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2629                Err(error) => return Err(error.into()),
2630            }
2631        }
2632
2633        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
2634        let snapshots = Arc::new(SnapshotRegistry::new());
2635        snapshots.configure_history(history_epochs, history_start);
2636        let page_cache = Arc::new(crate::cache::Sharded::new(
2637            crate::cache::CACHE_SHARDS,
2638            || {
2639                crate::cache::PageCache::new(
2640                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2641                )
2642            },
2643        ));
2644        let decoded_cache = Arc::new(crate::cache::Sharded::new(
2645            crate::cache::CACHE_SHARDS,
2646            || {
2647                crate::cache::DecodedPageCache::new(
2648                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2649                )
2650            },
2651        ));
2652        let commit_lock = Arc::new(Mutex::new(()));
2653        let shared_wal = Arc::new(Mutex::new(if existing {
2654            crate::wal::SharedWal::open_durable_root_validated(
2655                Arc::clone(&durable_root),
2656                Epoch(cat.db_epoch),
2657                wal_dek.clone(),
2658                Some(&recovery_records),
2659            )?
2660        } else {
2661            crate::wal::SharedWal::create_with_durable_root(
2662                Arc::clone(&durable_root),
2663                Epoch(cat.db_epoch),
2664                wal_dek.clone(),
2665            )?
2666        }));
2667        // Shared write-path state handed to every mounted table so single-table
2668        // `put`/`commit` writes route through the one shared WAL, the one group-
2669        // commit coordinator, and the one poison flag (B1).
2670        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
2671        let group = Arc::new(crate::txn::GroupCommit::new(
2672            shared_wal.lock().durable_seq(),
2673        ));
2674        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
2675        // Final base value is set after the open-generation bump below; tables
2676        // only draw ids once the user issues a write (post-open), so the
2677        // placeholder is never observed.
2678        let txn_ids = Arc::new(Mutex::new(1u64));
2679        let _ = abandoned;
2680
2681        // Build the shared auth state early — it's cloned into every mounted
2682        // Table's SharedCtx so the Table layer can enforce permissions without
2683        // a reference back to Database. The `require_auth` flag is mirrored
2684        // from the catalog; `enable_auth` / `refresh_principal` update it live.
2685        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
2686        let security_coordinator = security_coordinator(&root, cat.security_version);
2687        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
2688            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
2689        ));
2690
2691        // Open every live table against the shared context. Mounted tables have
2692        // no private WAL (B1) — `open_in` just loads the manifest/runs and
2693        // advances the shared epoch authority to its manifest epoch, so the
2694        // final shared watermark is the max across all tables. All of a mounted
2695        // table's committed records are replayed below from the shared WAL.
2696        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
2697        for entry in &cat.tables {
2698            if !matches!(entry.state, TableState::Live) {
2699                continue;
2700            }
2701            let table_root = match table_roots.remove(&entry.table_id) {
2702                Some(root) => root,
2703                None => Arc::new(
2704                    durable_root
2705                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
2706                ),
2707            };
2708            let tdir = table_root.io_path()?;
2709            let ctx = SharedCtx {
2710                root_guard: Some(table_root),
2711                epoch: Arc::clone(&epoch),
2712                page_cache: Arc::clone(&page_cache),
2713                decoded_cache: Arc::clone(&decoded_cache),
2714                snapshots: Arc::clone(&snapshots),
2715                kek: kek.clone(),
2716                commit_lock: Arc::clone(&commit_lock),
2717                shared: Some(crate::engine::SharedWalCtx {
2718                    wal: Arc::clone(&shared_wal),
2719                    group: Arc::clone(&group),
2720                    poisoned: Arc::clone(&poisoned),
2721                    txn_ids: Arc::clone(&txn_ids),
2722                    change_wake: change_wake.clone(),
2723                }),
2724                table_name: Some(entry.name.clone()),
2725                auth: auth_checker.clone(),
2726                read_only,
2727            };
2728            let t = Table::open_in(&tdir, ctx)?;
2729            tables.insert(entry.table_id, TableHandle::new(t));
2730        }
2731
2732        // Recover transaction writes from the shared WAL (spec §15). This is the
2733        // single durability source for mounted tables: it applies every committed
2734        // record — both single-table `Table::commit` writes and cross-table
2735        // transactions — gated by each table's `flushed_epoch` (records already
2736        // durable in a run are not re-applied).
2737        if existing {
2738            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
2739            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
2740            if read_only {
2741                crate::replication::reconcile_replica_epoch_durable(
2742                    &durable_root,
2743                    epoch.visible().0,
2744                )?;
2745            }
2746            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
2747            // large transactions (spec §8.5, review fix #14).
2748            sweep_pending_txn_dirs(&root, &cat);
2749        }
2750
2751        // Persist only after all semantic recovery and table mounting succeeds.
2752        catalog::write_generation(&durable_root, open_generation)?;
2753        shared_wal.lock().seal_open_generation(open_generation)?;
2754        crate::replication::replication_identity_durable(&durable_root)?;
2755        let next_txn_id = (open_generation << 32) | 1;
2756        // Seed the shared txn-id allocator now that the generation is final.
2757        *txn_ids.lock() = next_txn_id;
2758        let mut lock = lock;
2759        lock.mark_open()?;
2760
2761        Ok(Self {
2762            root,
2763            durable_root,
2764            read_only,
2765            catalog: RwLock::new(cat),
2766            security_coordinator,
2767            security_catalog_disk_reads: AtomicU64::new(0),
2768            rls_cache: Mutex::new(RlsCache::default()),
2769            epoch,
2770            snapshots,
2771            page_cache,
2772            decoded_cache,
2773            commit_lock,
2774            shared_wal,
2775            next_txn_id: txn_ids,
2776            tables: RwLock::new(tables),
2777            kek,
2778            ddl_lock: Mutex::new(()),
2779            meta_dek,
2780            conflicts: crate::txn::ConflictIndex::new(),
2781            active_txns: crate::txn::ActiveTxns::new(),
2782            poisoned,
2783            group,
2784            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
2785            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
2786            replication_barrier: parking_lot::RwLock::new(()),
2787            replication_wal_retention_segments: AtomicUsize::new(0),
2788            backup_pins: Arc::new(Mutex::new(HashMap::new())),
2789            spill_hook: Mutex::new(None),
2790            security_commit_hook: Mutex::new(None),
2791            catalog_commit_hook: Mutex::new(None),
2792            backup_hook: Mutex::new(None),
2793            replication_hook: Mutex::new(None),
2794            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
2795            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
2796            trigger_max_loop_iterations: AtomicU32::new(
2797                TriggerConfig::default().max_loop_iterations,
2798            ),
2799            notify: {
2800                let (tx, _rx) = tokio::sync::broadcast::channel(256);
2801                tx
2802            },
2803            change_wake,
2804            principal: RwLock::new(principal),
2805            auth_state,
2806            _lock: Some(lock),
2807        })
2808    }
2809
2810    /// The current reader-visible epoch.
2811    pub fn visible_epoch(&self) -> Epoch {
2812        self.epoch.visible()
2813    }
2814
2815    /// Clone the in-memory catalog (for diagnostics / tests).
2816    pub fn catalog_snapshot(&self) -> Catalog {
2817        self.catalog.read().clone()
2818    }
2819
2820    /// Read SQLite-compatible application metadata persisted in the catalog.
2821    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
2822        let catalog = self.catalog.read();
2823        match key {
2824            "user_version" => Ok(catalog.user_version),
2825            "application_id" => Ok(catalog.application_id),
2826            _ => Err(MongrelError::InvalidArgument(format!(
2827                "unsupported persistent SQL pragma {key:?}"
2828            ))),
2829        }
2830    }
2831
2832    /// Persist SQLite-compatible application metadata and return its exact
2833    /// publication epoch. An unchanged value performs no durable write.
2834    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
2835        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
2836    }
2837
2838    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
2839        &self,
2840        key: &str,
2841        value: i64,
2842        mut before_commit: F,
2843    ) -> Result<Option<Epoch>>
2844    where
2845        F: FnMut() -> Result<()>,
2846    {
2847        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
2848    }
2849
2850    fn set_sql_pragma_i64_with_epoch_inner(
2851        &self,
2852        key: &str,
2853        value: i64,
2854        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2855    ) -> Result<Option<Epoch>> {
2856        use crate::wal::DdlOp;
2857
2858        self.require(&crate::auth::Permission::Ddl)?;
2859        if self.read_only {
2860            return Err(MongrelError::ReadOnlyReplica);
2861        }
2862        if self.poisoned.load(Ordering::Relaxed) {
2863            return Err(MongrelError::Other(
2864                "database poisoned by fsync error".into(),
2865            ));
2866        }
2867        let _ddl = self.ddl_lock.lock();
2868        let _security_write = self.security_write()?;
2869        self.require(&crate::auth::Permission::Ddl)?;
2870        let mut next_catalog = self.catalog.read().clone();
2871        let target = match key {
2872            "user_version" => &mut next_catalog.user_version,
2873            "application_id" => &mut next_catalog.application_id,
2874            _ => {
2875                return Err(MongrelError::InvalidArgument(format!(
2876                    "unsupported persistent SQL pragma {key:?}"
2877                )))
2878            }
2879        };
2880        if *target == Some(value) {
2881            return Ok(None);
2882        }
2883        *target = Some(value);
2884
2885        let _commit = self.commit_lock.lock();
2886        let epoch = self.epoch.bump_assigned();
2887        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2888        let txn_id = self.alloc_txn_id()?;
2889        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2890        let commit_seq = {
2891            let mut wal = self.shared_wal.lock();
2892            if let Some(before_commit) = before_commit {
2893                before_commit()?;
2894            }
2895            let append: Result<u64> = (|| {
2896                wal.append(
2897                    txn_id,
2898                    WAL_TABLE_ID,
2899                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
2900                        key: key.to_string(),
2901                        value,
2902                    }),
2903                )?;
2904                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2905                wal.append_commit(txn_id, epoch, &[])
2906            })();
2907            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2908        };
2909        self.await_durable_commit(commit_seq, epoch)?;
2910        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2911        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2912        Ok(Some(epoch))
2913    }
2914
2915    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
2916        self.catalog
2917            .read()
2918            .materialized_views
2919            .iter()
2920            .find(|definition| definition.name == name)
2921            .cloned()
2922    }
2923
2924    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
2925        self.catalog.read().materialized_views.clone()
2926    }
2927
2928    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
2929        self.catalog.read().security.clone()
2930    }
2931
2932    pub fn security_active_for(&self, table: &str) -> bool {
2933        self.catalog.read().security.table_has_security(table)
2934    }
2935
2936    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
2937        if self.catalog.read().security_version == expected_version {
2938            return Ok(());
2939        }
2940        self.security_catalog_disk_reads
2941            .fetch_add(1, Ordering::Relaxed);
2942        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
2943            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
2944        let principal = self.principal.read().clone();
2945        let principal = if fresh.require_auth {
2946            principal
2947                .as_ref()
2948                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
2949        } else {
2950            principal
2951        };
2952        self.auth_state.set_require_auth(fresh.require_auth);
2953        *self.catalog.write() = fresh;
2954        *self.principal.write() = principal.clone();
2955        self.auth_state.set_principal(principal);
2956        Ok(())
2957    }
2958
2959    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
2960        let guard = self.security_coordinator.gate.write();
2961        let version = self.security_coordinator.version.load(Ordering::Acquire);
2962        self.refresh_security_catalog_if_stale(version)?;
2963        Ok(guard)
2964    }
2965
2966    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
2967    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
2968    /// only its restart checkpoint.
2969    fn publish_catalog_candidate(
2970        &self,
2971        catalog: Catalog,
2972        epoch: Epoch,
2973        epoch_guard: &mut EpochGuard<'_>,
2974        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2975    ) -> Result<()> {
2976        self.publish_catalog_candidate_with_prelude(
2977            catalog,
2978            epoch,
2979            epoch_guard,
2980            before_publish,
2981            Vec::new(),
2982        )
2983    }
2984
2985    fn publish_catalog_candidate_with_prelude(
2986        &self,
2987        catalog: Catalog,
2988        epoch: Epoch,
2989        epoch_guard: &mut EpochGuard<'_>,
2990        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2991        prelude: Vec<(u64, crate::wal::Op)>,
2992    ) -> Result<()> {
2993        use crate::wal::DdlOp;
2994
2995        if self.read_only {
2996            return Err(MongrelError::ReadOnlyReplica);
2997        }
2998        if self.poisoned.load(Ordering::Relaxed) {
2999            return Err(MongrelError::Other(
3000                "database poisoned by fsync error".into(),
3001            ));
3002        }
3003        if let Some(before_publish) = before_publish.as_mut() {
3004            (**before_publish)()?;
3005        }
3006        if catalog.db_epoch != epoch.0 {
3007            return Err(MongrelError::InvalidArgument(format!(
3008                "catalog epoch {} does not match commit epoch {}",
3009                catalog.db_epoch, epoch.0
3010            )));
3011        }
3012        {
3013            let current = self.catalog.read();
3014            validate_catalog_transition(&current, &catalog)?;
3015        }
3016        validate_recovered_catalog(&catalog)?;
3017        let catalog_json = DdlOp::encode_catalog(&catalog)?;
3018        let txn_id = self.alloc_txn_id()?;
3019        let commit_seq = {
3020            let mut wal = self.shared_wal.lock();
3021            let append: Result<u64> = (|| {
3022                for (table_id, op) in prelude {
3023                    wal.append(txn_id, table_id, op)?;
3024                }
3025                wal.append(
3026                    txn_id,
3027                    WAL_TABLE_ID,
3028                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
3029                )?;
3030                wal.append_commit(txn_id, epoch, &[])
3031            })();
3032            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
3033        };
3034        self.await_durable_commit(commit_seq, epoch)?;
3035        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
3036        self.finish_durable_publish(epoch, epoch_guard, checkpoint)
3037    }
3038
3039    /// A WAL commit is already durable. Publish the matching catalog in memory
3040    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
3041    /// while the live handle must never continue with pre-commit metadata.
3042    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
3043        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
3044        let version = catalog.security_version;
3045        let principal = self.principal.read().clone();
3046        let principal = if catalog.require_auth {
3047            principal.as_ref().and_then(|principal| {
3048                Self::resolve_bound_principal_from_catalog(&catalog, principal)
3049            })
3050        } else {
3051            principal
3052        };
3053        *self.catalog.write() = catalog;
3054        self.security_coordinator
3055            .version
3056            .store(version, Ordering::Release);
3057        self.auth_state
3058            .set_require_auth(self.catalog.read().require_auth);
3059        *self.principal.write() = principal.clone();
3060        self.auth_state.set_principal(principal);
3061        checkpoint
3062    }
3063
3064    fn finish_durable_publish(
3065        &self,
3066        epoch: Epoch,
3067        epoch_guard: &mut EpochGuard<'_>,
3068        post_step: Result<()>,
3069    ) -> Result<()> {
3070        self.epoch.publish_in_order(epoch);
3071        epoch_guard.disarm();
3072        match post_step {
3073            Ok(()) => Ok(()),
3074            Err(error) => {
3075                self.poisoned.store(true, Ordering::Relaxed);
3076                Err(MongrelError::DurableCommit {
3077                    epoch: epoch.0,
3078                    message: error.to_string(),
3079                })
3080            }
3081        }
3082    }
3083
3084    /// Wait for a commit marker to reach stable storage. A failed append/fsync
3085    /// acknowledgement is ambiguous, so poison the live handle and preserve
3086    /// the assigned epoch in a structured unknown-outcome error.
3087    fn await_durable_commit(&self, commit_seq: u64, epoch: Epoch) -> Result<()> {
3088        match self.group.await_durable(&self.shared_wal, commit_seq) {
3089            Ok(()) => Ok(()),
3090            Err(error) => {
3091                self.poisoned.store(true, Ordering::Relaxed);
3092                Err(MongrelError::CommitOutcomeUnknown {
3093                    epoch: epoch.0,
3094                    message: error.to_string(),
3095                })
3096            }
3097        }
3098    }
3099
3100    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
3101        self.poisoned.store(true, Ordering::Relaxed);
3102        MongrelError::CommitOutcomeUnknown {
3103            epoch: epoch.0,
3104            message: error.to_string(),
3105        }
3106    }
3107
3108    /// Persist a complete validated RLS/masking catalog through the WAL.
3109    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
3110        self.set_security_catalog_as_with_epoch(security, None)
3111            .map(|_| ())
3112    }
3113
3114    /// Persist security policy changes on behalf of an explicit request principal.
3115    pub fn set_security_catalog_as(
3116        &self,
3117        security: crate::security::SecurityCatalog,
3118        principal: Option<&crate::auth::Principal>,
3119    ) -> Result<()> {
3120        self.set_security_catalog_as_with_epoch(security, principal)
3121            .map(|_| ())
3122    }
3123
3124    /// Persist security policy changes and return the exact publication epoch.
3125    pub fn set_security_catalog_as_with_epoch(
3126        &self,
3127        security: crate::security::SecurityCatalog,
3128        principal: Option<&crate::auth::Principal>,
3129    ) -> Result<Epoch> {
3130        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
3131    }
3132
3133    /// Persist security policy changes, entering the commit fence immediately
3134    /// before the first WAL record can become visible to recovery.
3135    pub fn set_security_catalog_as_with_epoch_controlled<F>(
3136        &self,
3137        security: crate::security::SecurityCatalog,
3138        principal: Option<&crate::auth::Principal>,
3139        mut before_commit: F,
3140    ) -> Result<Epoch>
3141    where
3142        F: FnMut() -> Result<()>,
3143    {
3144        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
3145    }
3146
3147    fn set_security_catalog_as_with_epoch_inner(
3148        &self,
3149        security: crate::security::SecurityCatalog,
3150        principal: Option<&crate::auth::Principal>,
3151        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
3152    ) -> Result<Epoch> {
3153        use crate::wal::DdlOp;
3154        use std::sync::atomic::Ordering;
3155
3156        self.require_for(principal, &crate::auth::Permission::Admin)?;
3157        if self.poisoned.load(Ordering::Relaxed) {
3158            return Err(MongrelError::Other(
3159                "database poisoned by fsync error".into(),
3160            ));
3161        }
3162        let _ddl = self.ddl_lock.lock();
3163        // DDL serializes first; write-path order after that is security gate ->
3164        // commit lock -> shared WAL.
3165        let _security_write = self.security_write()?;
3166        self.require_for(principal, &crate::auth::Permission::Admin)?;
3167        let mut next_catalog = self.catalog.read().clone();
3168        validate_security_catalog(&next_catalog, &security)?;
3169        let payload = DdlOp::encode_security(&security)?;
3170        let _commit = self.commit_lock.lock();
3171        let epoch = self.epoch.bump_assigned();
3172        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3173        let txn_id = self.alloc_txn_id()?;
3174        next_catalog.security = security;
3175        advance_security_version(&mut next_catalog)?;
3176        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
3177        let commit_seq = {
3178            let mut wal = self.shared_wal.lock();
3179            if let Some(before_commit) = before_commit {
3180                before_commit()?;
3181            }
3182            let append: Result<u64> = (|| {
3183                wal.append(
3184                    txn_id,
3185                    WAL_TABLE_ID,
3186                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
3187                        security_json: payload,
3188                    }),
3189                )?;
3190                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
3191                wal.append_commit(txn_id, epoch, &[])
3192            })();
3193            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
3194        };
3195        self.await_durable_commit(commit_seq, epoch)?;
3196        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
3197        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
3198        Ok(epoch)
3199    }
3200
3201    pub fn require_for(
3202        &self,
3203        principal: Option<&crate::auth::Principal>,
3204        permission: &crate::auth::Permission,
3205    ) -> Result<()> {
3206        let Some(principal) = principal else {
3207            return self.require(permission);
3208        };
3209        let resolved;
3210        let principal = if self.auth_state.require_auth() || principal.user_id != 0 {
3211            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
3212                .ok_or(MongrelError::AuthRequired)?;
3213            &resolved
3214        } else {
3215            principal
3216        };
3217        #[cfg(test)]
3218        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
3219        if principal.has_permission(permission) {
3220            Ok(())
3221        } else {
3222            Err(MongrelError::PermissionDenied {
3223                required: permission.clone(),
3224                principal: principal.username.clone(),
3225            })
3226        }
3227    }
3228
3229    /// Recheck the exact operation principal while the caller holds the
3230    /// security gate. This deliberately performs no refresh or nested gate
3231    /// acquisition.
3232    fn require_exact_principal_current(
3233        &self,
3234        principal: Option<&crate::auth::Principal>,
3235        permission: &crate::auth::Permission,
3236    ) -> Result<()> {
3237        let catalog = self.catalog.read();
3238        if !catalog.require_auth {
3239            return Ok(());
3240        }
3241        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
3242        let current = Self::resolve_bound_principal_from_catalog(&catalog, supplied)
3243            .ok_or(MongrelError::AuthRequired)?;
3244        if current.has_permission(permission) {
3245            Ok(())
3246        } else {
3247            Err(MongrelError::PermissionDenied {
3248                required: permission.clone(),
3249                principal: current.username,
3250            })
3251        }
3252    }
3253
3254    pub(crate) fn with_exact_principal_current<T, F>(
3255        &self,
3256        principal: Option<&crate::auth::Principal>,
3257        permission: &crate::auth::Permission,
3258        operation: F,
3259    ) -> Result<T>
3260    where
3261        F: FnOnce() -> Result<T>,
3262    {
3263        let _security = self.security_coordinator.gate.read();
3264        self.require_exact_principal_current(principal, permission)?;
3265        operation()
3266    }
3267
3268    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
3269        self.principal.read().clone()
3270    }
3271
3272    #[cfg(test)]
3273    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
3274        *self.principal.write() = principal.clone();
3275        self.auth_state.set_principal(principal);
3276    }
3277
3278    pub fn require_columns_for(
3279        &self,
3280        table: &str,
3281        operation: crate::auth::ColumnOperation,
3282        column_ids: &[u16],
3283        principal: Option<&crate::auth::Principal>,
3284    ) -> Result<()> {
3285        if principal.is_none() && !self.auth_state.require_auth() {
3286            return Ok(());
3287        }
3288        let cached = self.principal.read().clone();
3289        let principal = principal.or(cached.as_ref());
3290        let Some(principal) = principal else {
3291            let permission = match operation {
3292                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3293                    table: table.to_string(),
3294                },
3295                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3296                    table: table.to_string(),
3297                },
3298                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3299                    table: table.to_string(),
3300                },
3301            };
3302            return self.require(&permission);
3303        };
3304        let catalog = self.catalog.read();
3305        let resolved;
3306        let principal = if catalog.require_auth || principal.user_id != 0 {
3307            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
3308                .ok_or(MongrelError::AuthRequired)?;
3309            &resolved
3310        } else {
3311            principal
3312        };
3313        let schema = &catalog
3314            .live(table)
3315            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3316            .schema;
3317        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
3318    }
3319
3320    fn require_columns_for_principal(
3321        table: &str,
3322        schema: &Schema,
3323        operation: crate::auth::ColumnOperation,
3324        column_ids: &[u16],
3325        principal: &crate::auth::Principal,
3326    ) -> Result<()> {
3327        #[cfg(test)]
3328        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
3329        match principal.column_access(table, operation) {
3330            crate::auth::ColumnAccess::All => Ok(()),
3331            crate::auth::ColumnAccess::Columns(allowed) => {
3332                let denied = column_ids.iter().find_map(|column_id| {
3333                    schema
3334                        .columns
3335                        .iter()
3336                        .find(|column| column.id == *column_id)
3337                        .filter(|column| !allowed.contains(&column.name))
3338                });
3339                if denied.is_none() {
3340                    Ok(())
3341                } else {
3342                    Err(MongrelError::PermissionDenied {
3343                        required: match operation {
3344                            crate::auth::ColumnOperation::Select => {
3345                                crate::auth::Permission::SelectColumns {
3346                                    table: table.to_string(),
3347                                    columns: denied
3348                                        .into_iter()
3349                                        .map(|column| column.name.clone())
3350                                        .collect(),
3351                                }
3352                            }
3353                            crate::auth::ColumnOperation::Insert => {
3354                                crate::auth::Permission::InsertColumns {
3355                                    table: table.to_string(),
3356                                    columns: denied
3357                                        .into_iter()
3358                                        .map(|column| column.name.clone())
3359                                        .collect(),
3360                                }
3361                            }
3362                            crate::auth::ColumnOperation::Update => {
3363                                crate::auth::Permission::UpdateColumns {
3364                                    table: table.to_string(),
3365                                    columns: denied
3366                                        .into_iter()
3367                                        .map(|column| column.name.clone())
3368                                        .collect(),
3369                                }
3370                            }
3371                        },
3372                        principal: principal.username.clone(),
3373                    })
3374                }
3375            }
3376            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3377                required: match operation {
3378                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3379                        table: table.to_string(),
3380                    },
3381                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3382                        table: table.to_string(),
3383                    },
3384                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3385                        table: table.to_string(),
3386                    },
3387                },
3388                principal: principal.username.clone(),
3389            }),
3390        }
3391    }
3392
3393    pub fn select_column_ids_for(
3394        &self,
3395        table: &str,
3396        principal: Option<&crate::auth::Principal>,
3397    ) -> Result<Vec<u16>> {
3398        let catalog = self.catalog.read();
3399        let columns = catalog
3400            .live(table)
3401            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3402            .schema
3403            .columns
3404            .iter()
3405            .map(|column| (column.id, column.name.clone()))
3406            .collect::<Vec<_>>();
3407        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
3408        drop(catalog);
3409        let Some(principal) = principal.as_ref() else {
3410            self.require(&crate::auth::Permission::Select {
3411                table: table.to_string(),
3412            })?;
3413            return Ok(columns.iter().map(|(id, _)| *id).collect());
3414        };
3415        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
3416            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
3417            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
3418                .iter()
3419                .filter(|(_, name)| allowed.contains(name))
3420                .map(|(id, _)| *id)
3421                .collect()),
3422            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3423                required: crate::auth::Permission::Select {
3424                    table: table.to_string(),
3425                },
3426                principal: principal.username.clone(),
3427            }),
3428        }
3429    }
3430
3431    pub fn secure_rows_for(
3432        &self,
3433        table: &str,
3434        rows: Vec<crate::memtable::Row>,
3435        principal: Option<&crate::auth::Principal>,
3436    ) -> Result<Vec<crate::memtable::Row>> {
3437        self.secure_rows_for_with_context(table, rows, principal, None)
3438    }
3439
3440    pub fn secure_rows_for_with_context(
3441        &self,
3442        table: &str,
3443        rows: Vec<crate::memtable::Row>,
3444        principal: Option<&crate::auth::Principal>,
3445        context: Option<&crate::query::AiExecutionContext>,
3446    ) -> Result<Vec<crate::memtable::Row>> {
3447        let (security, principal) = {
3448            let catalog = self.catalog.read();
3449            (
3450                catalog.security.clone(),
3451                self.principal_for_authorized_read(&catalog, principal, false)?,
3452            )
3453        };
3454        if !security.table_has_security(table) {
3455            return Ok(rows);
3456        }
3457        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3458        let mut output = Vec::new();
3459        for mut row in rows {
3460            if let Some(context) = context {
3461                context.consume(1)?;
3462            }
3463            if security.row_allowed(
3464                table,
3465                crate::security::PolicyCommand::Select,
3466                &row,
3467                principal,
3468                false,
3469            ) {
3470                security.apply_masks(table, &mut row, principal);
3471                output.push(row);
3472            }
3473        }
3474        Ok(output)
3475    }
3476
3477    /// Apply column masks to already RLS-authorized scored hits without a
3478    /// second row gather or policy evaluation.
3479    pub fn mask_search_hits_for(
3480        &self,
3481        table: &str,
3482        hits: &mut [crate::query::SearchHit],
3483        principal: Option<&crate::auth::Principal>,
3484    ) -> Result<()> {
3485        let (security, principal) = {
3486            let catalog = self.catalog.read();
3487            (
3488                catalog.security.clone(),
3489                self.principal_for_authorized_read(&catalog, principal, false)?,
3490            )
3491        };
3492        if !security.table_has_security(table) {
3493            return Ok(());
3494        }
3495        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3496        for hit in hits {
3497            security.apply_masks_to_cells(table, &mut hit.cells, principal);
3498        }
3499        Ok(())
3500    }
3501
3502    /// Apply masks to rows already admitted by candidate-aware RLS.
3503    pub fn mask_rows_for(
3504        &self,
3505        table: &str,
3506        rows: &mut [crate::memtable::Row],
3507        principal: Option<&crate::auth::Principal>,
3508    ) -> Result<()> {
3509        let (security, principal) = {
3510            let catalog = self.catalog.read();
3511            (
3512                catalog.security.clone(),
3513                self.principal_for_authorized_read(&catalog, principal, false)?,
3514            )
3515        };
3516        if !security.table_has_security(table) {
3517            return Ok(());
3518        }
3519        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3520        for row in rows {
3521            security.apply_masks(table, row, principal);
3522        }
3523        Ok(())
3524    }
3525
3526    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
3527    pub fn authorized_candidate_ids_for(
3528        &self,
3529        table: &str,
3530        principal: Option<&crate::auth::Principal>,
3531    ) -> Result<Option<std::collections::HashSet<RowId>>> {
3532        Ok(self
3533            .authorized_read_snapshot(table, principal)?
3534            .allowed_row_ids)
3535    }
3536
3537    fn allowed_row_ids_locked(
3538        &self,
3539        table_name: &str,
3540        table: &Table,
3541        table_snapshot: Snapshot,
3542        security_state: (&crate::security::SecurityCatalog, u64),
3543        principal: Option<&crate::auth::Principal>,
3544        context: Option<&crate::query::AiExecutionContext>,
3545    ) -> Result<Option<Arc<HashSet<RowId>>>> {
3546        let (security, security_version) = security_state;
3547        if !security.rls_enabled(table_name) {
3548            return Ok(None);
3549        }
3550        let authorization_started = std::time::Instant::now();
3551        let principal = principal.ok_or(MongrelError::AuthRequired)?;
3552        let mut roles = principal.roles.clone();
3553        roles.sort_unstable();
3554        let principal_key = format!(
3555            "{}:{}:{}:{}:{roles:?}",
3556            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
3557        );
3558        let cache_key = (
3559            table_name.to_string(),
3560            table.data_generation(),
3561            security_version,
3562            principal_key,
3563        );
3564        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
3565            crate::trace::QueryTrace::record(|trace| {
3566                trace.rls_cache_hit = true;
3567                trace.authorization_nanos = trace
3568                    .authorization_nanos
3569                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3570            });
3571            return Ok(Some(allowed));
3572        }
3573        if let Some(context) = context {
3574            context.checkpoint()?;
3575        }
3576        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
3577        let started = std::time::Instant::now();
3578        let rows = table.visible_rows(table_snapshot)?;
3579        let rows_evaluated = rows.len() as u64;
3580        let mut allowed = HashSet::new();
3581        for chunk in rows.chunks(256) {
3582            if let Some(context) = context {
3583                context.consume(chunk.len())?;
3584            }
3585            allowed.extend(chunk.iter().filter_map(|row| {
3586                security
3587                    .row_allowed(
3588                        table_name,
3589                        crate::security::PolicyCommand::Select,
3590                        row,
3591                        principal,
3592                        false,
3593                    )
3594                    .then_some(row.row_id)
3595            }));
3596        }
3597        let allowed = Arc::new(allowed);
3598        let mut cache = self.rls_cache.lock();
3599        cache.build_nanos = cache
3600            .build_nanos
3601            .saturating_add(started.elapsed().as_nanos() as u64);
3602        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
3603        cache.insert(cache_key, Arc::clone(&allowed));
3604        crate::trace::QueryTrace::record(|trace| {
3605            trace.rls_rows_evaluated = trace
3606                .rls_rows_evaluated
3607                .saturating_add(rows_evaluated as usize);
3608            trace.authorization_nanos = trace
3609                .authorization_nanos
3610                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3611        });
3612        Ok(Some(allowed))
3613    }
3614
3615    fn principal_for_authorized_read(
3616        &self,
3617        catalog: &Catalog,
3618        principal: Option<&crate::auth::Principal>,
3619        catalog_bound: bool,
3620    ) -> Result<Option<crate::auth::Principal>> {
3621        let principal = principal.cloned().or_else(|| self.principal.read().clone());
3622        let Some(principal) = principal else {
3623            return Ok(None);
3624        };
3625        if catalog.require_auth || catalog_bound || principal.user_id != 0 {
3626            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
3627                .map(Some)
3628                .ok_or(MongrelError::AuthRequired);
3629        }
3630        Ok(Some(principal))
3631    }
3632
3633    /// Run authorization, candidate generation, ranking, and materialization
3634    /// while holding one table generation. Security changes cause a bounded
3635    /// retry before any result is published.
3636    pub fn with_authorized_read<T, F>(
3637        &self,
3638        table_name: &str,
3639        principal: Option<&crate::auth::Principal>,
3640        catalog_bound: bool,
3641        read: F,
3642    ) -> Result<T>
3643    where
3644        F: FnMut(
3645            &mut Table,
3646            Snapshot,
3647            Option<&HashSet<RowId>>,
3648            Option<&crate::auth::Principal>,
3649        ) -> Result<T>,
3650    {
3651        self.with_authorized_read_context(
3652            table_name,
3653            principal,
3654            catalog_bound,
3655            None,
3656            None,
3657            None,
3658            read,
3659        )
3660    }
3661
3662    #[allow(clippy::too_many_arguments)]
3663    pub fn with_authorized_read_context<T, F>(
3664        &self,
3665        table_name: &str,
3666        principal: Option<&crate::auth::Principal>,
3667        catalog_bound: bool,
3668        authorization: Option<&ReadAuthorization>,
3669        context: Option<&crate::query::AiExecutionContext>,
3670        snapshot_override: Option<Snapshot>,
3671        read: F,
3672    ) -> Result<T>
3673    where
3674        F: FnMut(
3675            &mut Table,
3676            Snapshot,
3677            Option<&HashSet<RowId>>,
3678            Option<&crate::auth::Principal>,
3679        ) -> Result<T>,
3680    {
3681        self.with_authorized_read_context_stamped(
3682            table_name,
3683            principal,
3684            catalog_bound,
3685            authorization,
3686            context,
3687            snapshot_override,
3688            read,
3689        )
3690        .map(|(result, _)| result)
3691    }
3692
3693    #[allow(clippy::too_many_arguments)]
3694    pub fn with_authorized_read_context_stamped<T, F>(
3695        &self,
3696        table_name: &str,
3697        principal: Option<&crate::auth::Principal>,
3698        catalog_bound: bool,
3699        authorization: Option<&ReadAuthorization>,
3700        context: Option<&crate::query::AiExecutionContext>,
3701        snapshot_override: Option<Snapshot>,
3702        mut read: F,
3703    ) -> Result<(T, AuthorizedReadStamp)>
3704    where
3705        F: FnMut(
3706            &mut Table,
3707            Snapshot,
3708            Option<&HashSet<RowId>>,
3709            Option<&crate::auth::Principal>,
3710        ) -> Result<T>,
3711    {
3712        if principal.is_none() && self.principal.read().is_some() {
3713            self.refresh_principal()?;
3714        }
3715        const RETRIES: usize = 3;
3716        let handle = self.table(table_name)?;
3717        for attempt in 0..RETRIES {
3718            crate::trace::QueryTrace::record(|trace| {
3719                trace.authorization_retries = attempt;
3720            });
3721            let (security, security_version, effective_principal) = {
3722                let catalog = self.catalog.read();
3723                (
3724                    catalog.security.clone(),
3725                    catalog.security_version,
3726                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3727                )
3728            };
3729            if let Some(authorization) = authorization {
3730                for permission in &authorization.permissions {
3731                    self.require_for(effective_principal.as_ref(), permission)?;
3732                }
3733                self.require_columns_for(
3734                    table_name,
3735                    authorization.operation,
3736                    &authorization.columns,
3737                    effective_principal.as_ref(),
3738                )?;
3739            }
3740            let result = {
3741                let mut table = lock_table_with_context(&handle, context)?;
3742                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
3743                let allowed = self.allowed_row_ids_locked(
3744                    table_name,
3745                    &table,
3746                    snapshot,
3747                    (&security, security_version),
3748                    effective_principal.as_ref(),
3749                    context,
3750                )?;
3751                let stamp = AuthorizedReadStamp {
3752                    table_id: table.table_id(),
3753                    schema_id: table.schema().schema_id,
3754                    data_generation: table.data_generation(),
3755                    security_version,
3756                    snapshot,
3757                };
3758                let result = read(
3759                    &mut table,
3760                    snapshot,
3761                    allowed.as_deref(),
3762                    effective_principal.as_ref(),
3763                )?;
3764                (result, stamp)
3765            };
3766            if let Some(context) = context {
3767                context.checkpoint()?;
3768            }
3769            if self.catalog.read().security_version == security_version {
3770                return Ok(result);
3771            }
3772            if attempt + 1 == RETRIES {
3773                return Err(MongrelError::Conflict(
3774                    "security policy changed during scored read".into(),
3775                ));
3776            }
3777        }
3778        Err(MongrelError::Conflict(
3779            "authorization retry loop exhausted".into(),
3780        ))
3781    }
3782
3783    fn with_authorized_aggregate_table<T, F>(
3784        &self,
3785        table_name: &str,
3786        columns: &[u16],
3787        principal: Option<&crate::auth::Principal>,
3788        catalog_bound: bool,
3789        allow_table_security: bool,
3790        mut aggregate: F,
3791    ) -> Result<T>
3792    where
3793        F: FnMut(
3794            &mut Table,
3795            Option<&crate::security::CandidateAuthorization<'_>>,
3796            Option<&crate::auth::Principal>,
3797            u64,
3798        ) -> Result<T>,
3799    {
3800        if principal.is_none() && self.principal.read().is_some() {
3801            self.refresh_principal()?;
3802        }
3803        const RETRIES: usize = 3;
3804        let handle = self.table(table_name)?;
3805        for attempt in 0..RETRIES {
3806            let (security, security_version, effective_principal) = {
3807                let catalog = self.catalog.read();
3808                (
3809                    catalog.security.clone(),
3810                    catalog.security_version,
3811                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3812                )
3813            };
3814            self.require_columns_for(
3815                table_name,
3816                crate::auth::ColumnOperation::Select,
3817                columns,
3818                effective_principal.as_ref(),
3819            )?;
3820            if !allow_table_security && security.table_has_security(table_name) {
3821                return Err(MongrelError::InvalidArgument(
3822                    "incremental aggregate is unsupported while RLS or column masks are active"
3823                        .into(),
3824                ));
3825            }
3826            let result = {
3827                let mut table = handle.lock();
3828                let authorization = if security.rls_enabled(table_name) {
3829                    Some(crate::security::CandidateAuthorization {
3830                        table: table_name,
3831                        security: &security,
3832                        principal: effective_principal
3833                            .as_ref()
3834                            .ok_or(MongrelError::AuthRequired)?,
3835                    })
3836                } else {
3837                    None
3838                };
3839                aggregate(
3840                    &mut table,
3841                    authorization.as_ref(),
3842                    effective_principal.as_ref(),
3843                    security_version,
3844                )?
3845            };
3846            if self.catalog.read().security_version == security_version {
3847                return Ok(result);
3848            }
3849            if attempt + 1 == RETRIES {
3850                return Err(MongrelError::Conflict(
3851                    "security policy changed during aggregate read".into(),
3852                ));
3853            }
3854        }
3855        Err(MongrelError::Conflict(
3856            "aggregate authorization retry loop exhausted".into(),
3857        ))
3858    }
3859
3860    /// Scored-read authorization that evaluates RLS only for approximate
3861    /// candidates. This avoids a full-table policy scan on cache misses while
3862    /// preserving one table generation and security-version retry.
3863    pub fn with_authorized_scored_read_context<T, F>(
3864        &self,
3865        table_name: &str,
3866        principal: Option<&crate::auth::Principal>,
3867        catalog_bound: bool,
3868        authorization: Option<&ReadAuthorization>,
3869        context: Option<&crate::query::AiExecutionContext>,
3870        mut read: F,
3871    ) -> Result<T>
3872    where
3873        F: FnMut(
3874            &mut Table,
3875            Snapshot,
3876            Option<&crate::security::CandidateAuthorization<'_>>,
3877            Option<&crate::auth::Principal>,
3878        ) -> Result<T>,
3879    {
3880        self.with_authorized_scored_read_context_at(
3881            table_name,
3882            principal,
3883            catalog_bound,
3884            authorization,
3885            context,
3886            None,
3887            |table, snapshot, authorization, principal| {
3888                let mut table = table.clone();
3889                read(&mut table, snapshot, authorization, principal)
3890            },
3891        )
3892    }
3893
3894    #[allow(clippy::too_many_arguments)]
3895    pub fn with_authorized_scored_read_context_at<T, F>(
3896        &self,
3897        table_name: &str,
3898        principal: Option<&crate::auth::Principal>,
3899        catalog_bound: bool,
3900        authorization: Option<&ReadAuthorization>,
3901        context: Option<&crate::query::AiExecutionContext>,
3902        snapshot_override: Option<Snapshot>,
3903        read: F,
3904    ) -> Result<T>
3905    where
3906        F: FnMut(
3907            &Table,
3908            Snapshot,
3909            Option<&crate::security::CandidateAuthorization<'_>>,
3910            Option<&crate::auth::Principal>,
3911        ) -> Result<T>,
3912    {
3913        self.with_authorized_scored_read_context_at_stamped(
3914            table_name,
3915            principal,
3916            catalog_bound,
3917            authorization,
3918            context,
3919            snapshot_override,
3920            read,
3921        )
3922        .map(|(result, _)| result)
3923    }
3924
3925    #[allow(clippy::too_many_arguments)]
3926    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
3927        &self,
3928        table_name: &str,
3929        principal: Option<&crate::auth::Principal>,
3930        catalog_bound: bool,
3931        authorization: Option<&ReadAuthorization>,
3932        context: Option<&crate::query::AiExecutionContext>,
3933        snapshot_override: Option<Snapshot>,
3934        mut read: F,
3935    ) -> Result<(T, AuthorizedReadStamp)>
3936    where
3937        F: FnMut(
3938            &Table,
3939            Snapshot,
3940            Option<&crate::security::CandidateAuthorization<'_>>,
3941            Option<&crate::auth::Principal>,
3942        ) -> Result<T>,
3943    {
3944        if principal.is_none() && self.principal.read().is_some() {
3945            self.refresh_principal()?;
3946        }
3947        const RETRIES: usize = 3;
3948        let handle = self.table(table_name)?;
3949        for attempt in 0..RETRIES {
3950            if let Some(context) = context {
3951                context.checkpoint()?;
3952            }
3953            crate::trace::QueryTrace::record(|trace| {
3954                trace.authorization_retries = attempt;
3955            });
3956            let (security, security_version, effective_principal) = {
3957                let catalog = self.catalog.read();
3958                (
3959                    catalog.security.clone(),
3960                    catalog.security_version,
3961                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3962                )
3963            };
3964            if let Some(authorization) = authorization {
3965                for permission in &authorization.permissions {
3966                    self.require_for(effective_principal.as_ref(), permission)?;
3967                }
3968                self.require_columns_for(
3969                    table_name,
3970                    authorization.operation,
3971                    &authorization.columns,
3972                    effective_principal.as_ref(),
3973                )?;
3974            }
3975            let result = {
3976                let (table, snapshot, _snapshot_guard, _run_pins) =
3977                    self.scored_read_generation(&handle, context, snapshot_override)?;
3978                let candidate_authorization = if security.rls_enabled(table_name) {
3979                    Some(crate::security::CandidateAuthorization {
3980                        table: table_name,
3981                        security: &security,
3982                        principal: effective_principal
3983                            .as_ref()
3984                            .ok_or(MongrelError::AuthRequired)?,
3985                    })
3986                } else {
3987                    None
3988                };
3989                let stamp = AuthorizedReadStamp {
3990                    table_id: table.table_id(),
3991                    schema_id: table.schema().schema_id,
3992                    data_generation: table.data_generation(),
3993                    security_version,
3994                    snapshot,
3995                };
3996                let result = read(
3997                    table.as_ref(),
3998                    snapshot,
3999                    candidate_authorization.as_ref(),
4000                    effective_principal.as_ref(),
4001                )?;
4002                (result, stamp)
4003            };
4004            if let Some(context) = context {
4005                context.checkpoint()?;
4006            }
4007            if self.catalog.read().security_version == security_version {
4008                return Ok(result);
4009            }
4010            if attempt + 1 == RETRIES {
4011                return Err(MongrelError::Conflict(
4012                    "security policy changed during scored read".into(),
4013                ));
4014            }
4015        }
4016        Err(MongrelError::Conflict(
4017            "scored-read authorization retry loop exhausted".into(),
4018        ))
4019    }
4020
4021    fn scored_read_generation(
4022        &self,
4023        handle: &TableHandle,
4024        context: Option<&crate::query::AiExecutionContext>,
4025        snapshot_override: Option<Snapshot>,
4026    ) -> Result<(
4027        Arc<TableReadGeneration>,
4028        Snapshot,
4029        crate::retention::OwnedSnapshotGuard,
4030        RunPins,
4031    )> {
4032        let mut table = if let Some(context) = context {
4033            loop {
4034                context.checkpoint()?;
4035                let wait = context
4036                    .remaining_duration()
4037                    .unwrap_or(std::time::Duration::from_millis(5))
4038                    .min(std::time::Duration::from_millis(5));
4039                if let Some(table) = handle.try_lock_for(wait) {
4040                    break table;
4041                }
4042            }
4043        } else {
4044            handle.lock()
4045        };
4046        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
4047            self.snapshot_at_owned(snapshot.epoch)?
4048        } else {
4049            let snapshot = table.snapshot();
4050            let guard = self.snapshots.register_owned(snapshot.epoch);
4051            (snapshot, guard)
4052        };
4053        let table_id = table.table_id();
4054        let run_keys: Vec<_> = table
4055            .active_run_ids()
4056            .map(|run_id| (table_id, run_id))
4057            .collect();
4058        let generation = handle
4059            .generation_metrics
4060            .activate(table.clone_read_generation()?);
4061        let run_pins = self.pin_runs(&run_keys);
4062        Ok((generation, snapshot, snapshot_guard, run_pins))
4063    }
4064
4065    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
4066        let mut pins = self.backup_pins.lock();
4067        for run in runs {
4068            *pins.entry(*run).or_insert(0) += 1;
4069        }
4070        drop(pins);
4071        RunPins {
4072            pins: Arc::clone(&self.backup_pins),
4073            runs: runs.to_vec(),
4074        }
4075    }
4076
4077    /// Execute a native conjunctive read with the database principal's row
4078    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
4079    /// policy-unaware; language bindings must use this boundary for reads.
4080    pub fn query_for_current_principal(
4081        &self,
4082        table_name: &str,
4083        query: &crate::query::Query,
4084        projection: Option<&[u16]>,
4085    ) -> Result<Vec<crate::memtable::Row>> {
4086        let condition_columns = crate::query::condition_columns(&query.conditions);
4087        self.with_authorized_read(
4088            table_name,
4089            None,
4090            true,
4091            |table, snapshot, allowed, principal| {
4092                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4093                self.require_columns_for(
4094                    table_name,
4095                    crate::auth::ColumnOperation::Select,
4096                    &condition_columns,
4097                    principal,
4098                )?;
4099                if let Some(projection) = projection {
4100                    self.require_columns_for(
4101                        table_name,
4102                        crate::auth::ColumnOperation::Select,
4103                        projection,
4104                        principal,
4105                    )?;
4106                }
4107                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
4108                let projection =
4109                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
4110                for row in &mut rows {
4111                    row.columns.retain(|column, _| {
4112                        allowed_columns.contains(column)
4113                            && projection
4114                                .as_ref()
4115                                .is_none_or(|projection| projection.contains(column))
4116                    });
4117                }
4118                self.secure_rows_for(table_name, rows, principal)
4119            },
4120        )
4121    }
4122
4123    /// Execute a secured native read with cooperative cancellation across
4124    /// authorization, candidate generation, materialization, masking, and
4125    /// projection.
4126    pub fn query_for_current_principal_controlled(
4127        &self,
4128        table_name: &str,
4129        query: &crate::query::Query,
4130        projection: Option<&[u16]>,
4131        control: &crate::ExecutionControl,
4132    ) -> Result<Vec<crate::memtable::Row>> {
4133        self.query_for_principal_controlled(table_name, query, projection, None, true, control)
4134    }
4135
4136    fn query_for_principal_controlled(
4137        &self,
4138        table_name: &str,
4139        query: &crate::query::Query,
4140        projection: Option<&[u16]>,
4141        principal: Option<&crate::auth::Principal>,
4142        catalog_bound: bool,
4143        control: &crate::ExecutionControl,
4144    ) -> Result<Vec<crate::memtable::Row>> {
4145        control.checkpoint()?;
4146        let context = crate::query::AiExecutionContext::with_control(
4147            control.clone(),
4148            usize::MAX,
4149            crate::query::MAX_FUSED_CANDIDATES,
4150        );
4151        let condition_columns = crate::query::condition_columns(&query.conditions);
4152        self.with_authorized_read_context(
4153            table_name,
4154            principal,
4155            catalog_bound,
4156            None,
4157            Some(&context),
4158            None,
4159            |table, snapshot, allowed, principal| {
4160                control.checkpoint()?;
4161                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4162                self.require_columns_for(
4163                    table_name,
4164                    crate::auth::ColumnOperation::Select,
4165                    &condition_columns,
4166                    principal,
4167                )?;
4168                if let Some(projection) = projection {
4169                    self.require_columns_for(
4170                        table_name,
4171                        crate::auth::ColumnOperation::Select,
4172                        projection,
4173                        principal,
4174                    )?;
4175                }
4176                let rows =
4177                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
4178                let projection =
4179                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
4180                let mut projected = Vec::with_capacity(rows.len());
4181                for (index, mut row) in rows.into_iter().enumerate() {
4182                    if index & 255 == 0 {
4183                        control.checkpoint()?;
4184                    }
4185                    row.columns.retain(|column, _| {
4186                        allowed_columns.contains(column)
4187                            && projection
4188                                .as_ref()
4189                                .is_none_or(|projection| projection.contains(column))
4190                    });
4191                    projected.push(row);
4192                }
4193                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
4194            },
4195        )
4196    }
4197
4198    /// Reservoir aggregate with column grants, RLS, masks, and security-version
4199    /// retry applied at the database boundary.
4200    pub fn approx_aggregate_for_current_principal(
4201        &self,
4202        table_name: &str,
4203        conditions: &[crate::query::Condition],
4204        column: Option<u16>,
4205        agg: crate::engine::ApproxAgg,
4206        z: f64,
4207    ) -> Result<Option<crate::engine::ApproxResult>> {
4208        if !z.is_finite() || z <= 0.0 {
4209            return Err(MongrelError::InvalidArgument(
4210                "z must be finite and > 0".into(),
4211            ));
4212        }
4213        let mut columns = crate::query::condition_columns(conditions);
4214        columns.extend(column);
4215        columns.sort_unstable();
4216        columns.dedup();
4217        self.with_authorized_aggregate_table(
4218            table_name,
4219            &columns,
4220            None,
4221            true,
4222            true,
4223            |table, authorization, _, _| {
4224                table.approx_aggregate_with_candidate_authorization(
4225                    conditions,
4226                    column,
4227                    agg,
4228                    z,
4229                    authorization,
4230                )
4231            },
4232        )
4233    }
4234
4235    /// Incremental aggregate over an append-only table. Active RLS or masks are
4236    /// rejected because the table-global delta cache cannot safely represent a
4237    /// secured row universe.
4238    pub fn incremental_aggregate_for_current_principal(
4239        &self,
4240        table_name: &str,
4241        conditions: &[crate::query::Condition],
4242        column: Option<u16>,
4243        agg: crate::engine::NativeAgg,
4244    ) -> Result<crate::engine::IncrementalAggResult> {
4245        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
4246    }
4247
4248    /// Incremental aggregate using an explicit request principal. A
4249    /// catalog-bound principal is re-resolved on every retry so live grants,
4250    /// revocations, RLS, and masks cannot reuse a stale cache entry.
4251    pub fn incremental_aggregate_for_principal(
4252        &self,
4253        table_name: &str,
4254        conditions: &[crate::query::Condition],
4255        column: Option<u16>,
4256        agg: crate::engine::NativeAgg,
4257        principal: Option<&crate::auth::Principal>,
4258        catalog_bound: bool,
4259    ) -> Result<crate::engine::IncrementalAggResult> {
4260        let mut columns = crate::query::condition_columns(conditions);
4261        columns.extend(column);
4262        columns.sort_unstable();
4263        columns.dedup();
4264        self.with_authorized_aggregate_table(
4265            table_name,
4266            &columns,
4267            principal,
4268            catalog_bound,
4269            false,
4270            |table, _, principal, security_version| {
4271                let cache_key = incremental_aggregate_cache_key(
4272                    table_name,
4273                    conditions,
4274                    column,
4275                    agg,
4276                    principal,
4277                    security_version,
4278                );
4279                table.aggregate_incremental(cache_key, conditions, column, agg)
4280            },
4281        )
4282    }
4283
4284    /// Read one row with the database principal's row policy, column grants,
4285    /// and masks applied.
4286    pub fn get_for_current_principal(
4287        &self,
4288        table_name: &str,
4289        row_id: RowId,
4290    ) -> Result<Option<crate::memtable::Row>> {
4291        self.with_authorized_read(
4292            table_name,
4293            None,
4294            true,
4295            |table, snapshot, allowed, principal| {
4296                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4297                let Some(row) = table.get(row_id, snapshot) else {
4298                    return Ok(None);
4299                };
4300                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
4301                    return Ok(None);
4302                }
4303                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
4304                if let Some(row) = rows.first_mut() {
4305                    row.columns
4306                        .retain(|column, _| allowed_columns.contains(column));
4307                }
4308                Ok(rows.pop())
4309            },
4310        )
4311    }
4312
4313    /// Run exact ANN reranking over only rows authorized for this database
4314    /// handle. The embedding column still requires normal column access.
4315    pub fn ann_rerank_for_current_principal(
4316        &self,
4317        table_name: &str,
4318        request: &crate::query::AnnRerankRequest,
4319    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4320        self.with_authorized_scored_read_context_at(
4321            table_name,
4322            None,
4323            true,
4324            Some(&ReadAuthorization {
4325                operation: crate::auth::ColumnOperation::Select,
4326                columns: vec![request.column_id],
4327                permissions: Vec::new(),
4328            }),
4329            None,
4330            None,
4331            |table, snapshot, authorization, principal| {
4332                self.require_columns_for(
4333                    table_name,
4334                    crate::auth::ColumnOperation::Select,
4335                    &[request.column_id],
4336                    principal,
4337                )?;
4338                table.ann_rerank_at_with_candidate_authorization_on_generation(
4339                    request,
4340                    snapshot,
4341                    authorization,
4342                    None,
4343                )
4344            },
4345        )
4346    }
4347
4348    /// Capture one table snapshot and the security version used to authorize it.
4349    /// The caller must validate the returned version before publishing results.
4350    pub fn authorized_read_snapshot(
4351        &self,
4352        table: &str,
4353        principal: Option<&crate::auth::Principal>,
4354    ) -> Result<AuthorizedReadSnapshot> {
4355        let (security, security_version, effective_principal) = {
4356            let catalog = self.catalog.read();
4357            (
4358                catalog.security.clone(),
4359                catalog.security_version,
4360                self.principal_for_authorized_read(&catalog, principal, false)?,
4361            )
4362        };
4363        let handle = self.table(table)?;
4364        let (table_snapshot, data_generation, allowed_row_ids) = {
4365            let table_handle = handle.lock();
4366            let table_snapshot = table_handle.snapshot();
4367            let data_generation = table_handle.data_generation();
4368            let allowed = self.allowed_row_ids_locked(
4369                table,
4370                &table_handle,
4371                table_snapshot,
4372                (&security, security_version),
4373                effective_principal.as_ref(),
4374                None,
4375            )?;
4376            (
4377                table_snapshot,
4378                data_generation,
4379                allowed.map(|allowed| (*allowed).clone()),
4380            )
4381        };
4382        Ok(AuthorizedReadSnapshot {
4383            table: table.to_string(),
4384            table_snapshot,
4385            data_generation,
4386            security_version,
4387            allowed_row_ids,
4388        })
4389    }
4390
4391    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
4392        if self.catalog.read().security_version != snapshot.security_version {
4393            return false;
4394        }
4395        self.table(&snapshot.table)
4396            .ok()
4397            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
4398    }
4399
4400    pub fn rls_cache_stats(&self) -> RlsCacheStats {
4401        self.rls_cache.lock().stats()
4402    }
4403
4404    /// Read visible rows with column authorization, RLS, and masks applied.
4405    pub fn rows_for(
4406        &self,
4407        table: &str,
4408        principal: Option<&crate::auth::Principal>,
4409    ) -> Result<Vec<crate::memtable::Row>> {
4410        if principal.is_none() && self.principal.read().is_some() {
4411            self.refresh_principal()?;
4412        }
4413        let allowed = self.select_column_ids_for(table, principal)?;
4414        let handle = self.table(table)?;
4415        let rows = {
4416            let table = handle.lock();
4417            table.visible_rows(table.snapshot())?
4418        };
4419        let mut rows = self.secure_rows_for(table, rows, principal)?;
4420        for row in &mut rows {
4421            row.columns.retain(|column, _| allowed.contains(column));
4422        }
4423        Ok(rows)
4424    }
4425
4426    /// Historical rows use the current principal and security catalog against
4427    /// the row values visible at the requested snapshot.
4428    pub fn rows_at_epoch_for_current_principal(
4429        &self,
4430        table_name: &str,
4431        snapshot: Snapshot,
4432    ) -> Result<Vec<crate::memtable::Row>> {
4433        self.with_authorized_read_context(
4434            table_name,
4435            None,
4436            true,
4437            Some(&ReadAuthorization {
4438                operation: crate::auth::ColumnOperation::Select,
4439                columns: Vec::new(),
4440                permissions: Vec::new(),
4441            }),
4442            None,
4443            Some(snapshot),
4444            |table, snapshot, allowed, principal| {
4445                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4446                let mut rows = table.visible_rows(snapshot)?;
4447                if let Some(allowed) = allowed {
4448                    rows.retain(|row| allowed.contains(&row.row_id));
4449                }
4450                rows = self.secure_rows_for(table_name, rows, principal)?;
4451                for row in &mut rows {
4452                    row.columns
4453                        .retain(|column, _| allowed_columns.contains(column));
4454                }
4455                Ok(rows)
4456            },
4457        )
4458    }
4459
4460    /// Count rows visible to a principal without bypassing RLS.
4461    pub fn count_for(
4462        &self,
4463        table: &str,
4464        principal: Option<&crate::auth::Principal>,
4465    ) -> Result<u64> {
4466        if principal.is_none() && self.principal.read().is_some() {
4467            self.refresh_principal()?;
4468        }
4469        self.select_column_ids_for(table, principal)?;
4470        if self.security_active_for(table) {
4471            Ok(self.rows_for(table, principal)?.len() as u64)
4472        } else {
4473            Ok(self.table(table)?.lock().count())
4474        }
4475    }
4476
4477    /// Authorize and write one native-API row for an explicit principal.
4478    pub fn put_for(
4479        &self,
4480        table: &str,
4481        mut cells: Vec<(u16, crate::memtable::Value)>,
4482        principal: Option<&crate::auth::Principal>,
4483    ) -> Result<RowId> {
4484        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
4485        self.require_columns_for(
4486            table,
4487            crate::auth::ColumnOperation::Insert,
4488            &columns,
4489            principal,
4490        )?;
4491        let handle = self.table(table)?;
4492        let mut table_handle = handle.lock();
4493        table_handle.fill_auto_inc(&mut cells)?;
4494        table_handle.apply_defaults(&mut cells)?;
4495        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
4496        row.columns.extend(cells.iter().cloned());
4497        self.check_row_policy_for(
4498            table,
4499            crate::security::PolicyCommand::Insert,
4500            &row,
4501            true,
4502            principal,
4503        )?;
4504        table_handle.put(cells)
4505    }
4506
4507    pub fn check_row_policy_for(
4508        &self,
4509        table: &str,
4510        command: crate::security::PolicyCommand,
4511        row: &crate::memtable::Row,
4512        check_new: bool,
4513        principal: Option<&crate::auth::Principal>,
4514    ) -> Result<()> {
4515        let security = self.catalog.read().security.clone();
4516        if !security.rls_enabled(table) {
4517            return Ok(());
4518        }
4519        let cached = self.principal.read().clone();
4520        let principal = principal
4521            .or(cached.as_ref())
4522            .ok_or(MongrelError::AuthRequired)?;
4523        if security.row_allowed(table, command, row, principal, check_new) {
4524            return Ok(());
4525        }
4526        let required = match command {
4527            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
4528                table: table.to_string(),
4529            },
4530            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
4531                table: table.to_string(),
4532            },
4533            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
4534                table: table.to_string(),
4535            },
4536            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
4537                crate::auth::Permission::Delete {
4538                    table: table.to_string(),
4539                }
4540            }
4541        };
4542        Err(MongrelError::PermissionDenied {
4543            required,
4544            principal: principal.username.clone(),
4545        })
4546    }
4547
4548    /// Durably create or replace a materialized-view definition after its
4549    /// physical table has been populated.
4550    pub fn set_materialized_view(
4551        &self,
4552        definition: crate::catalog::MaterializedViewEntry,
4553    ) -> Result<()> {
4554        self.set_materialized_view_with_epoch(definition)
4555            .map(|_| ())
4556    }
4557
4558    /// Durably create or replace a materialized-view definition and return its epoch.
4559    pub fn set_materialized_view_with_epoch(
4560        &self,
4561        definition: crate::catalog::MaterializedViewEntry,
4562    ) -> Result<Epoch> {
4563        use crate::wal::DdlOp;
4564        use std::sync::atomic::Ordering;
4565
4566        self.require(&crate::auth::Permission::Ddl)?;
4567        if self.poisoned.load(Ordering::Relaxed) {
4568            return Err(MongrelError::Other(
4569                "database poisoned by fsync error".into(),
4570            ));
4571        }
4572        if definition.name.is_empty() || definition.query.trim().is_empty() {
4573            return Err(MongrelError::InvalidArgument(
4574                "materialized view name and query must not be empty".into(),
4575            ));
4576        }
4577
4578        let _ddl = self.ddl_lock.lock();
4579        let _security_write = self.security_write()?;
4580        self.require(&crate::auth::Permission::Ddl)?;
4581        let table_id = self
4582            .catalog
4583            .read()
4584            .live(&definition.name)
4585            .ok_or_else(|| {
4586                MongrelError::NotFound(format!(
4587                    "materialized view table {:?} not found",
4588                    definition.name
4589                ))
4590            })?
4591            .table_id;
4592        let definition_json = DdlOp::encode_materialized_view(&definition)?;
4593        let _commit = self.commit_lock.lock();
4594        let epoch = self.epoch.bump_assigned();
4595        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4596        let txn_id = self.alloc_txn_id()?;
4597        let mut next_catalog = self.catalog.read().clone();
4598        if let Some(existing) = next_catalog
4599            .materialized_views
4600            .iter_mut()
4601            .find(|existing| existing.name == definition.name)
4602        {
4603            *existing = definition.clone();
4604        } else {
4605            next_catalog.materialized_views.push(definition.clone());
4606        }
4607        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4608        let commit_seq = {
4609            let mut wal = self.shared_wal.lock();
4610            let append: Result<u64> = (|| {
4611                wal.append(
4612                    txn_id,
4613                    table_id,
4614                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
4615                        name: definition.name.clone(),
4616                        definition_json,
4617                    }),
4618                )?;
4619                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4620                wal.append_commit(txn_id, epoch, &[])
4621            })();
4622            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4623        };
4624        self.await_durable_commit(commit_seq, epoch)?;
4625
4626        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4627        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
4628        Ok(epoch)
4629    }
4630
4631    /// The filesystem root this database was opened/created at.
4632    pub fn root(&self) -> &Path {
4633        self.durable_root.canonical_path()
4634    }
4635
4636    /// Open a descriptor-pinned view of this database root for durable
4637    /// extension state such as server idempotency receipts.
4638    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
4639        Arc::clone(&self.durable_root)
4640    }
4641
4642    /// Domain-separated authentication key for server idempotency state.
4643    /// Encrypted databases derive it from the in-memory KEK. Plain databases
4644    /// return `None`; their server persists a random key under the pinned root.
4645    #[cfg(feature = "encryption")]
4646    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4647        self.kek
4648            .as_deref()
4649            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
4650    }
4651
4652    #[cfg(not(feature = "encryption"))]
4653    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4654        None
4655    }
4656
4657    pub fn is_read_only_replica(&self) -> bool {
4658        self.read_only
4659    }
4660
4661    /// Reject reads whose backing state may require WAL recovery after a
4662    /// post-commit publication failure. Ordinary table/catalog state is made
4663    /// coherent before poison; file-backed external modules use this gate.
4664    pub fn ensure_consistent_read(&self) -> Result<()> {
4665        self.ensure_owner_process()?;
4666        if self.poisoned.load(Ordering::Relaxed) {
4667            return Err(MongrelError::Other(
4668                "database poisoned by post-commit failure; reopen required".into(),
4669            ));
4670        }
4671        Ok(())
4672    }
4673
4674    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
4675        self.replication_wal_retention_segments
4676            .store(segments, std::sync::atomic::Ordering::Relaxed);
4677    }
4678
4679    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
4680    /// direct table commits, compaction, and WAL append are quiesced while the
4681    /// file image is read. WAL records newer than manifests remain sufficient
4682    /// for recovery, so no flush or compaction is required.
4683    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
4684        let admin = crate::auth::Permission::Admin;
4685        self.require(&admin)?;
4686        let operation_principal = self.principal_snapshot();
4687        let _barrier = self.replication_barrier.write();
4688        let _ddl = self.ddl_lock.lock();
4689        let _security = self.security_coordinator.gate.read();
4690        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4691        let mut handles: Vec<_> = self
4692            .tables
4693            .read()
4694            .iter()
4695            .map(|(id, handle)| (*id, handle.clone()))
4696            .collect();
4697        handles.sort_by_key(|(id, _)| *id);
4698        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4699        let _commit = self.commit_lock.lock();
4700        let mut wal = self.shared_wal.lock();
4701        wal.group_sync()?;
4702        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4703        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4704        let epoch = records
4705            .iter()
4706            .filter_map(|record| match &record.op {
4707                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
4708                _ => None,
4709            })
4710            .max()
4711            .unwrap_or(0)
4712            .max(self.epoch.committed().0);
4713        let files = crate::replication::capture_files(&self.root)?;
4714        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4715        drop(wal);
4716        Ok(crate::replication::ReplicationSnapshot::new(
4717            source_id, epoch, files,
4718        ))
4719    }
4720
4721    /// Create an online, directly-openable backup at `destination`.
4722    ///
4723    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
4724    /// mutable metadata, and pins the exact immutable runs named by the copied
4725    /// manifests. Writers resume while those runs stream into a sibling staging
4726    /// directory. A checksummed backup manifest is written last, then the stage
4727    /// is atomically renamed into place.
4728    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
4729        let control = crate::ExecutionControl::new(None);
4730        self.hot_backup_controlled(destination, &control, || true)
4731    }
4732
4733    pub(crate) fn hot_backup_to_durable_child(
4734        &self,
4735        parent: &crate::durable_file::DurableRoot,
4736        child: &Path,
4737        control: &crate::ExecutionControl,
4738    ) -> Result<crate::backup::BackupReport> {
4739        let mut components = child.components();
4740        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
4741            || components.next().is_some()
4742        {
4743            return Err(MongrelError::InvalidArgument(
4744                "durable backup child must be one relative path component".into(),
4745            ));
4746        }
4747        let destination_name = child.file_name().ok_or_else(|| {
4748            MongrelError::InvalidArgument("durable backup child has no filename".into())
4749        })?;
4750        let prepared = prepare_backup_destination_in(&self.root, parent, destination_name)?;
4751        self.hot_backup_prepared(prepared, control, || true)
4752    }
4753
4754    /// Build a backup cooperatively, then invoke `before_publish` immediately
4755    /// before the staging directory is atomically renamed into place.
4756    #[doc(hidden)]
4757    pub fn hot_backup_controlled<F>(
4758        &self,
4759        destination: impl AsRef<Path>,
4760        control: &crate::ExecutionControl,
4761        before_publish: F,
4762    ) -> Result<crate::backup::BackupReport>
4763    where
4764        F: FnOnce() -> bool,
4765    {
4766        let prepared = prepare_backup_destination(&self.root, destination.as_ref())?;
4767        self.hot_backup_prepared(prepared, control, before_publish)
4768    }
4769
4770    fn hot_backup_prepared<F>(
4771        &self,
4772        mut prepared: PreparedBackupDestination,
4773        control: &crate::ExecutionControl,
4774        before_publish: F,
4775    ) -> Result<crate::backup::BackupReport>
4776    where
4777        F: FnOnce() -> bool,
4778    {
4779        let admin = crate::auth::Permission::Admin;
4780        self.require(&admin)?;
4781        let operation_principal = self.principal_snapshot();
4782        control.checkpoint()?;
4783        let destination = prepared.destination_path.clone();
4784        let mut before_publish = Some(before_publish);
4785
4786        let outcome = (|| {
4787            control.checkpoint()?;
4788            let barrier = self.replication_barrier.write();
4789            let ddl = self.ddl_lock.lock();
4790            let security = self.security_coordinator.gate.read();
4791            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4792            let mut handles: Vec<_> = self
4793                .tables
4794                .read()
4795                .iter()
4796                .map(|(id, handle)| (*id, handle.clone()))
4797                .collect();
4798            handles.sort_by_key(|(id, _)| *id);
4799            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4800            let commit = self.commit_lock.lock();
4801            let mut wal = self.shared_wal.lock();
4802            wal.group_sync()?;
4803            let epoch = self.epoch.committed().0;
4804            let boundary_unix_nanos = current_unix_nanos();
4805
4806            let pin_nonce = std::time::SystemTime::now()
4807                .duration_since(std::time::UNIX_EPOCH)
4808                .unwrap_or_default()
4809                .as_nanos();
4810            let file_pin_root = self
4811                .root
4812                .join(META_DIR)
4813                .join("backup-pins")
4814                .join(format!("{}-{pin_nonce}", std::process::id()));
4815            std::fs::create_dir_all(&file_pin_root)?;
4816            let _file_pins = BackupFilePins {
4817                root: file_pin_root.clone(),
4818            };
4819            let mut run_files = Vec::new();
4820            for (index, (table_id, _)) in handles.iter().enumerate() {
4821                if index % 256 == 0 {
4822                    control.checkpoint()?;
4823                }
4824                let table = &table_guards[index];
4825                for (run_index, run) in table.run_refs().iter().enumerate() {
4826                    if run_index % 256 == 0 {
4827                        control.checkpoint()?;
4828                    }
4829                    let source = table.run_path(run.run_id as u64);
4830                    let relative = Path::new(TABLES_DIR)
4831                        .join(table_id.to_string())
4832                        .join(crate::engine::RUNS_DIR)
4833                        .join(format!("r-{}.sr", run.run_id));
4834                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
4835                    if std::fs::hard_link(&source, &pinned).is_err() {
4836                        crate::backup::copy_file_synced(&source, &pinned)?;
4837                    }
4838                    run_files.push(((*table_id, run.run_id), pinned, relative));
4839                }
4840            }
4841            crate::durable_file::sync_directory(&file_pin_root)?;
4842            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
4843            {
4844                let mut pins = self.backup_pins.lock();
4845                for key in &run_keys {
4846                    *pins.entry(*key).or_insert(0) += 1;
4847                }
4848            }
4849            let _run_pins = RunPins {
4850                pins: Arc::clone(&self.backup_pins),
4851                runs: run_keys,
4852            };
4853            let deferred: HashSet<_> = run_files
4854                .iter()
4855                .map(|(_, _, relative)| relative.clone())
4856                .collect();
4857            let mut copied = Vec::new();
4858            copy_backup_boundary(
4859                &self.root,
4860                prepared.stage.as_deref().ok_or_else(|| {
4861                    MongrelError::Other("backup staging root was already released".into())
4862                })?,
4863                &deferred,
4864                &mut copied,
4865                Some(control),
4866            )?;
4867
4868            drop(wal);
4869            drop(commit);
4870            drop(table_guards);
4871            drop(security);
4872            drop(ddl);
4873            drop(barrier);
4874
4875            if let Some(hook) = self.backup_hook.lock().as_ref() {
4876                hook();
4877            }
4878            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
4879                if index % 256 == 0 {
4880                    control.checkpoint()?;
4881                }
4882                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
4883                prepared
4884                    .stage
4885                    .as_deref()
4886                    .ok_or_else(|| {
4887                        MongrelError::Other("backup staging root was already released".into())
4888                    })?
4889                    .copy_new_from(&relative, &mut source)?;
4890                copied.push(relative);
4891            }
4892
4893            let manifest = crate::backup::BackupManifest::create_controlled_durable(
4894                prepared.stage.as_deref().ok_or_else(|| {
4895                    MongrelError::Other("backup staging root was already released".into())
4896                })?,
4897                epoch,
4898                &copied,
4899                control,
4900            )?;
4901            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
4902                MongrelError::Other("backup staging root was already released".into())
4903            })?)?;
4904            control.checkpoint()?;
4905            let publish = before_publish.take().ok_or_else(|| {
4906                MongrelError::Other("backup publication callback already consumed".into())
4907            })?;
4908            if !publish() {
4909                return Err(MongrelError::Cancelled);
4910            }
4911            let final_security = self.security_coordinator.gate.read();
4912            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4913            // Windows pins directories without delete sharing. Release the
4914            // stage handle before renaming that directory, while the parent
4915            // remains descriptor-pinned for the no-replace publication.
4916            drop(prepared.stage.take().ok_or_else(|| {
4917                MongrelError::Other("backup staging root was already released".into())
4918            })?);
4919            let published = std::cell::Cell::new(false);
4920            if let Err(error) = prepared.parent.rename_directory_new_with_after(
4921                Path::new(&prepared.stage_name),
4922                &prepared.parent,
4923                Path::new(&prepared.destination_name),
4924                || published.set(true),
4925            ) {
4926                if published.get() {
4927                    return Err(MongrelError::CommitOutcomeUnknown {
4928                        epoch,
4929                        message: format!("backup publication was not durable: {error}"),
4930                    });
4931                }
4932                return Err(error.into());
4933            }
4934            drop(final_security);
4935            Ok(crate::backup::BackupReport {
4936                destination,
4937                epoch,
4938                boundary_unix_nanos,
4939                files: manifest.files.len(),
4940                bytes: manifest.total_bytes(),
4941            })
4942        })();
4943
4944        if outcome.is_err() {
4945            drop(prepared.stage.take());
4946            let _ = prepared
4947                .parent
4948                .remove_directory_all(Path::new(&prepared.stage_name));
4949        }
4950        outcome
4951    }
4952
4953    /// Return complete committed transactions after `since_epoch`. A gap or a
4954    /// transaction backed by a spilled run requires a fresh bootstrap image.
4955    pub fn replication_batch_since(
4956        &self,
4957        since_epoch: u64,
4958    ) -> Result<crate::replication::ReplicationBatch> {
4959        use crate::wal::Op;
4960
4961        let admin = crate::auth::Permission::Admin;
4962        self.require(&admin)?;
4963        let operation_principal = self.principal_snapshot();
4964
4965        let mut wal = self.shared_wal.lock();
4966        wal.group_sync()?;
4967        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4968        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4969        drop(wal);
4970
4971        let commits: HashMap<u64, u64> = records
4972            .iter()
4973            .filter_map(|record| match &record.op {
4974                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
4975                _ => None,
4976            })
4977            .collect();
4978        let earliest_epoch = commits.values().copied().min();
4979        let current_epoch = commits
4980            .values()
4981            .copied()
4982            .max()
4983            .unwrap_or(0)
4984            .max(self.epoch.committed().0);
4985        let selected: HashSet<u64> = commits
4986            .iter()
4987            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
4988            .collect();
4989        let retention_gap = since_epoch < current_epoch
4990            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
4991        let spilled = records.iter().any(|record| {
4992            selected.contains(&record.txn_id)
4993                && matches!(
4994                    &record.op,
4995                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
4996                )
4997        });
4998        let records = records
4999            .into_iter()
5000            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
5001            .filter(|record| selected.contains(&record.txn_id))
5002            .collect();
5003        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
5004        let batch = crate::replication::ReplicationBatch::complete_for_source(
5005            source_id,
5006            since_epoch,
5007            current_epoch,
5008            earliest_epoch,
5009            retention_gap,
5010            spilled,
5011            records,
5012        )?;
5013        if let Some(hook) = self.replication_hook.lock().as_ref() {
5014            hook();
5015        }
5016        let _security = self.security_coordinator.gate.read();
5017        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
5018        Ok(batch)
5019    }
5020
5021    /// Durably append a leader batch to a follower's local WAL and checkpoint
5022    /// its catalog metadata. Security changes apply to this live handle before
5023    /// success returns. The caller must reopen to mount new table state.
5024    pub fn append_replication_batch(
5025        &self,
5026        batch: &crate::replication::ReplicationBatch,
5027    ) -> Result<u64> {
5028        use crate::wal::Op;
5029
5030        if !self.read_only {
5031            return Err(MongrelError::InvalidArgument(
5032                "replication batches may only target a marked replica".into(),
5033            ));
5034        }
5035        let current = crate::replication::replica_epoch(&self.root)?;
5036        if batch.is_source_bound() {
5037            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
5038            if batch.source_id != source_id {
5039                return Err(MongrelError::Conflict(
5040                    "replication batch source does not match follower binding".into(),
5041                ));
5042            }
5043        }
5044        if batch.requires_snapshot {
5045            return Err(MongrelError::Conflict(
5046                "replication snapshot required for this batch".into(),
5047            ));
5048        }
5049        batch.validate_proof()?;
5050        if batch.from_epoch != current {
5051            if batch.from_epoch < current && batch.current_epoch == current {
5052                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
5053                let _wal = self.shared_wal.lock();
5054                let existing: HashSet<(u64, u64)> =
5055                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
5056                        .into_iter()
5057                        .filter_map(|record| match record.op {
5058                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
5059                            _ => None,
5060                        })
5061                        .collect();
5062                let already_applied = batch.records.iter().all(|record| match &record.op {
5063                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
5064                    _ => true,
5065                });
5066                if already_applied {
5067                    return Ok(current);
5068                }
5069            }
5070            return Err(MongrelError::Conflict(format!(
5071                "replication batch starts at epoch {}, follower is at epoch {current}",
5072                batch.from_epoch
5073            )));
5074        }
5075        if batch.current_epoch < current {
5076            return Err(MongrelError::InvalidArgument(format!(
5077                "replication batch current epoch {} precedes follower epoch {current}",
5078                batch.current_epoch
5079            )));
5080        }
5081        let records = &batch.records;
5082        let mut commits = HashMap::new();
5083        let mut commit_epochs = HashSet::new();
5084        let mut commit_timestamps = HashMap::new();
5085        for record in records {
5086            match &record.op {
5087                Op::TxnCommit { epoch, added_runs } => {
5088                    if !added_runs.is_empty() {
5089                        return Err(MongrelError::Conflict(
5090                            "replication snapshot required for spilled-run transaction".into(),
5091                        ));
5092                    }
5093                    if commits.insert(record.txn_id, *epoch).is_some() {
5094                        return Err(MongrelError::InvalidArgument(format!(
5095                            "duplicate commit for replication transaction {}",
5096                            record.txn_id
5097                        )));
5098                    }
5099                    if *epoch <= current || *epoch > batch.current_epoch {
5100                        return Err(MongrelError::InvalidArgument(format!(
5101                            "replication commit epoch {epoch} is outside ({current}, {}]",
5102                            batch.current_epoch
5103                        )));
5104                    }
5105                    if !commit_epochs.insert(*epoch) {
5106                        return Err(MongrelError::InvalidArgument(format!(
5107                            "duplicate replication commit epoch {epoch}"
5108                        )));
5109                    }
5110                }
5111                Op::CommitTimestamp { unix_nanos } => {
5112                    commit_timestamps.insert(record.txn_id, *unix_nanos);
5113                }
5114                _ => {}
5115            }
5116        }
5117        for record in records {
5118            if record.txn_id == crate::wal::SYSTEM_TXN_ID
5119                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
5120            {
5121                return Err(MongrelError::InvalidArgument(
5122                    "replication batch contains a non-committed record".into(),
5123                ));
5124            }
5125            if !commits.contains_key(&record.txn_id) {
5126                return Err(MongrelError::InvalidArgument(format!(
5127                    "incomplete replication transaction {}",
5128                    record.txn_id
5129                )));
5130            }
5131        }
5132        let target_epoch = commits
5133            .values()
5134            .copied()
5135            .filter(|epoch| *epoch > current)
5136            .max()
5137            .unwrap_or(current);
5138        if target_epoch != batch.current_epoch {
5139            return Err(MongrelError::InvalidArgument(format!(
5140                "replication batch ends at epoch {target_epoch}, expected {}",
5141                batch.current_epoch
5142            )));
5143        }
5144        let mut selected: HashSet<u64> = commits
5145            .iter()
5146            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
5147            .collect();
5148        if selected.is_empty() {
5149            return Ok(current);
5150        }
5151        let mut wal = self.shared_wal.lock();
5152        wal.group_sync()?;
5153        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
5154        let existing: HashSet<(u64, u64)> =
5155            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
5156                .into_iter()
5157                .filter_map(|record| match record.op {
5158                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
5159                    _ => None,
5160                })
5161                .collect();
5162        selected.retain(|txn_id| {
5163            commits
5164                .get(txn_id)
5165                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
5166        });
5167        for record in records {
5168            if !selected.contains(&record.txn_id) {
5169                continue;
5170            }
5171            match &record.op {
5172                Op::TxnCommit { epoch, added_runs } => {
5173                    let timestamp = commit_timestamps
5174                        .get(&record.txn_id)
5175                        .copied()
5176                        .unwrap_or_else(current_unix_nanos);
5177                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
5178                }
5179                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
5180                op => {
5181                    wal.append(record.txn_id, 0, op.clone())?;
5182                }
5183            }
5184        }
5185        if !selected.is_empty() {
5186            wal.group_sync()?;
5187        }
5188        drop(wal);
5189
5190        // Auth mode is selected before `finish_open` replays the WAL. Make the
5191        // catalog transition durable and publish security state to this live
5192        // handle before reporting success.
5193        let mut recovered_catalog = self.catalog.read().clone();
5194        if let Err(error) = recover_ddl_from_wal(
5195            &self.root,
5196            Some(&self.durable_root),
5197            &mut recovered_catalog,
5198            self.meta_dek.as_ref(),
5199            wal_dek.as_ref(),
5200            true,
5201            None,
5202        ) {
5203            return Err(MongrelError::DurableCommit {
5204                epoch: target_epoch,
5205                message: format!(
5206                    "replication WAL is durable but catalog checkpoint failed: {error}"
5207                ),
5208            });
5209        }
5210        let _security = self.security_coordinator.gate.write();
5211        let old_security_version = self.catalog.read().security_version;
5212        let security_changed = old_security_version != recovered_catalog.security_version
5213            || self.catalog.read().require_auth != recovered_catalog.require_auth;
5214        let require_auth = recovered_catalog.require_auth;
5215        let principal = if security_changed {
5216            None
5217        } else {
5218            self.principal.read().as_ref().and_then(|principal| {
5219                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
5220            })
5221        };
5222        if require_auth {
5223            self.auth_state.set_require_auth(true);
5224        }
5225        self.auth_state.set_principal(principal.clone());
5226        *self.principal.write() = principal;
5227        let security_version = recovered_catalog.security_version;
5228        *self.catalog.write() = recovered_catalog;
5229        self.security_coordinator
5230            .version
5231            .store(security_version, Ordering::Release);
5232        if !require_auth {
5233            self.auth_state.set_require_auth(false);
5234        }
5235        if let Err(error) =
5236            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
5237        {
5238            return Err(MongrelError::DurableCommit {
5239                epoch: target_epoch,
5240                message: format!(
5241                    "replication WAL and catalog are durable but follower watermark failed: {error}"
5242                ),
5243            });
5244        }
5245        Ok(target_epoch)
5246    }
5247
5248    /// Resolve a table name → id (live tables only). pub(crate) so the
5249    /// transaction layer can stage by name.
5250    pub fn table_id(&self, name: &str) -> Result<u64> {
5251        let cat = self.catalog.read();
5252        cat.live(name)
5253            .map(|e| e.table_id)
5254            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
5255    }
5256
5257    /// Return the stable table id and current schema generation from one
5258    /// catalog snapshot. Callers can bind retries to this identity so a table
5259    /// dropped and recreated under the same name is never mistaken for the
5260    /// original resource.
5261    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
5262        let catalog = self.catalog.read();
5263        catalog
5264            .live(name)
5265            .map(|entry| (entry.table_id, entry.schema.schema_id))
5266            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
5267    }
5268
5269    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
5270        self.catalog
5271            .read()
5272            .building(name)
5273            .map(|entry| entry.table_id)
5274            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
5275    }
5276
5277    pub fn procedures(&self) -> Vec<StoredProcedure> {
5278        self.catalog
5279            .read()
5280            .procedures
5281            .iter()
5282            .map(|p| p.procedure.clone())
5283            .collect()
5284    }
5285
5286    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
5287        self.catalog
5288            .read()
5289            .procedures
5290            .iter()
5291            .find(|p| p.procedure.name == name)
5292            .map(|p| p.procedure.clone())
5293    }
5294
5295    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
5296        self.create_procedure_inner(procedure, None)
5297    }
5298
5299    pub fn create_procedure_controlled<F>(
5300        &self,
5301        procedure: StoredProcedure,
5302        mut before_publish: F,
5303    ) -> Result<StoredProcedure>
5304    where
5305        F: FnMut() -> Result<()>,
5306    {
5307        self.create_procedure_inner(procedure, Some(&mut before_publish))
5308    }
5309
5310    fn create_procedure_inner(
5311        &self,
5312        mut procedure: StoredProcedure,
5313        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5314    ) -> Result<StoredProcedure> {
5315        self.require(&crate::auth::Permission::Ddl)?;
5316        let _g = self.ddl_lock.lock();
5317        let _security_write = self.security_write()?;
5318        self.require(&crate::auth::Permission::Ddl)?;
5319        procedure.validate()?;
5320        self.validate_procedure_references(&procedure)?;
5321        {
5322            let cat = self.catalog.read();
5323            if cat
5324                .procedures
5325                .iter()
5326                .any(|p| p.procedure.name == procedure.name)
5327            {
5328                return Err(MongrelError::InvalidArgument(format!(
5329                    "procedure {:?} already exists",
5330                    procedure.name
5331                )));
5332            }
5333        }
5334        let commit_lock = Arc::clone(&self.commit_lock);
5335        let _c = commit_lock.lock();
5336        let epoch = self.epoch.bump_assigned();
5337        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5338        procedure.created_epoch = epoch.0;
5339        procedure.updated_epoch = epoch.0;
5340        let mut next_catalog = self.catalog.read().clone();
5341        next_catalog
5342            .procedures
5343            .push(ProcedureEntry::from(procedure.clone()));
5344        next_catalog.db_epoch = epoch.0;
5345        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5346        Ok(procedure)
5347    }
5348
5349    pub fn create_or_replace_procedure(
5350        &self,
5351        procedure: StoredProcedure,
5352    ) -> Result<StoredProcedure> {
5353        self.create_or_replace_procedure_inner(procedure, None)
5354    }
5355
5356    pub fn create_or_replace_procedure_controlled<F>(
5357        &self,
5358        procedure: StoredProcedure,
5359        mut before_publish: F,
5360    ) -> Result<StoredProcedure>
5361    where
5362        F: FnMut() -> Result<()>,
5363    {
5364        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
5365    }
5366
5367    fn create_or_replace_procedure_inner(
5368        &self,
5369        procedure: StoredProcedure,
5370        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5371    ) -> Result<StoredProcedure> {
5372        self.require(&crate::auth::Permission::Ddl)?;
5373        let _g = self.ddl_lock.lock();
5374        let _security_write = self.security_write()?;
5375        self.require(&crate::auth::Permission::Ddl)?;
5376        procedure.validate()?;
5377        self.validate_procedure_references(&procedure)?;
5378        let commit_lock = Arc::clone(&self.commit_lock);
5379        let _c = commit_lock.lock();
5380        let epoch = self.epoch.bump_assigned();
5381        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5382        let mut next_catalog = self.catalog.read().clone();
5383        let replaced = {
5384            let next = match next_catalog
5385                .procedures
5386                .iter()
5387                .position(|p| p.procedure.name == procedure.name)
5388            {
5389                Some(idx) => {
5390                    let next = next_catalog.procedures[idx]
5391                        .procedure
5392                        .replaced(procedure.clone(), epoch.0)?;
5393                    next_catalog.procedures[idx] = ProcedureEntry::from(next.clone());
5394                    next
5395                }
5396                None => {
5397                    let mut next = procedure;
5398                    next.created_epoch = epoch.0;
5399                    next.updated_epoch = epoch.0;
5400                    next_catalog
5401                        .procedures
5402                        .push(ProcedureEntry::from(next.clone()));
5403                    next
5404                }
5405            };
5406            next_catalog.db_epoch = epoch.0;
5407            next
5408        };
5409        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5410        Ok(replaced)
5411    }
5412
5413    pub fn drop_procedure(&self, name: &str) -> Result<()> {
5414        self.drop_procedure_with_epoch(name).map(|_| ())
5415    }
5416
5417    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
5418        self.drop_procedure_with_epoch_inner(name, None)
5419    }
5420
5421    pub fn drop_procedure_with_epoch_controlled<F>(
5422        &self,
5423        name: &str,
5424        mut before_publish: F,
5425    ) -> Result<Epoch>
5426    where
5427        F: FnMut() -> Result<()>,
5428    {
5429        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
5430    }
5431
5432    fn drop_procedure_with_epoch_inner(
5433        &self,
5434        name: &str,
5435        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5436    ) -> Result<Epoch> {
5437        self.require(&crate::auth::Permission::Ddl)?;
5438        let _g = self.ddl_lock.lock();
5439        let _security_write = self.security_write()?;
5440        self.require(&crate::auth::Permission::Ddl)?;
5441        let commit_lock = Arc::clone(&self.commit_lock);
5442        let _c = commit_lock.lock();
5443        let epoch = self.epoch.bump_assigned();
5444        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5445        let mut next_catalog = self.catalog.read().clone();
5446        let before = next_catalog.procedures.len();
5447        next_catalog.procedures.retain(|p| p.procedure.name != name);
5448        if next_catalog.procedures.len() == before {
5449            return Err(MongrelError::NotFound(format!(
5450                "procedure {name:?} not found"
5451            )));
5452        }
5453        next_catalog.db_epoch = epoch.0;
5454        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5455        Ok(epoch)
5456    }
5457
5458    // ── User / role / credentials management ─────────────────────────────
5459
5460    /// List all catalog users (password hashes included — callers should not
5461    /// serialize them externally).
5462    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
5463        self.catalog.read().users.clone()
5464    }
5465
5466    /// Resolve only the stable, non-secret identity fields needed to scope
5467    /// request receipts. Password hashes never leave the catalog lock.
5468    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
5469        self.catalog
5470            .read()
5471            .users
5472            .iter()
5473            .find(|user| user.username == username)
5474            .map(|user| (user.id, user.created_epoch))
5475    }
5476
5477    /// Current catalog authorization generation. Retry bindings can include
5478    /// this value to fail closed after roles, grants, or row policies change.
5479    pub fn security_version(&self) -> u64 {
5480        self.catalog.read().security_version
5481    }
5482
5483    /// List all catalog roles.
5484    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
5485        self.catalog.read().roles.clone()
5486    }
5487
5488    /// Create a new user with an Argon2id-hashed password.
5489    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
5490        self.require(&crate::auth::Permission::Admin)?;
5491        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
5492        self.create_user_with_password_hash(username, hash)
5493    }
5494
5495    /// Create a user from a password hash prepared before a commit fence.
5496    pub fn create_user_with_password_hash(
5497        &self,
5498        username: &str,
5499        hash: String,
5500    ) -> Result<crate::auth::UserEntry> {
5501        self.create_user_with_password_hash_inner(username, hash, None)
5502    }
5503
5504    pub fn create_user_with_password_hash_controlled<F>(
5505        &self,
5506        username: &str,
5507        hash: String,
5508        mut before_publish: F,
5509    ) -> Result<crate::auth::UserEntry>
5510    where
5511        F: FnMut() -> Result<()>,
5512    {
5513        self.create_user_with_password_hash_inner(username, hash, Some(&mut before_publish))
5514    }
5515
5516    fn create_user_with_password_hash_inner(
5517        &self,
5518        username: &str,
5519        hash: String,
5520        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5521    ) -> Result<crate::auth::UserEntry> {
5522        self.require(&crate::auth::Permission::Admin)?;
5523        let _ddl = self.ddl_lock.lock();
5524        let _security_write = self.security_write()?;
5525        self.require(&crate::auth::Permission::Admin)?;
5526        let _commit = self.commit_lock.lock();
5527        let epoch = self.epoch.bump_assigned();
5528        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5529        let mut next_catalog = self.catalog.read().clone();
5530        if next_catalog.users.iter().any(|u| u.username == username) {
5531            return Err(MongrelError::InvalidArgument(format!(
5532                "user {username:?} already exists"
5533            )));
5534        }
5535        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
5536        let id = next_catalog.next_user_id;
5537        next_catalog.next_user_id = id
5538            .checked_add(1)
5539            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
5540        let entry = crate::auth::UserEntry {
5541            id,
5542            username: username.into(),
5543            password_hash: hash,
5544            roles: Vec::new(),
5545            is_admin: false,
5546            created_epoch: epoch.0,
5547        };
5548        next_catalog.users.push(entry.clone());
5549        advance_security_version(&mut next_catalog)?;
5550        next_catalog.db_epoch = epoch.0;
5551        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5552        Ok(entry)
5553    }
5554
5555    /// Drop a user by username.
5556    pub fn drop_user(&self, username: &str) -> Result<()> {
5557        self.drop_user_with_epoch(username).map(|_| ())
5558    }
5559
5560    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
5561        self.drop_user_with_epoch_inner(username, None)
5562    }
5563
5564    pub fn drop_user_with_epoch_controlled<F>(
5565        &self,
5566        username: &str,
5567        mut before_publish: F,
5568    ) -> Result<Epoch>
5569    where
5570        F: FnMut() -> Result<()>,
5571    {
5572        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
5573    }
5574
5575    fn drop_user_with_epoch_inner(
5576        &self,
5577        username: &str,
5578        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5579    ) -> Result<Epoch> {
5580        self.require(&crate::auth::Permission::Admin)?;
5581        let _ddl = self.ddl_lock.lock();
5582        let _security_write = self.security_write()?;
5583        self.require(&crate::auth::Permission::Admin)?;
5584        let _commit = self.commit_lock.lock();
5585        let epoch = self.epoch.bump_assigned();
5586        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5587        let mut next_catalog = self.catalog.read().clone();
5588        let before = next_catalog.users.len();
5589        next_catalog.users.retain(|u| u.username != username);
5590        if next_catalog.users.len() == before {
5591            return Err(MongrelError::NotFound(format!(
5592                "user {username:?} not found"
5593            )));
5594        }
5595        advance_security_version(&mut next_catalog)?;
5596        next_catalog.db_epoch = epoch.0;
5597        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5598        Ok(epoch)
5599    }
5600
5601    /// Change a user's password.
5602    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
5603        self.alter_user_password_with_epoch(username, new_password)
5604            .map(|_| ())
5605    }
5606
5607    pub fn alter_user_password_with_epoch(
5608        &self,
5609        username: &str,
5610        new_password: &str,
5611    ) -> Result<Epoch> {
5612        self.require(&crate::auth::Permission::Admin)?;
5613        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
5614        self.alter_user_password_hash_with_epoch(username, hash)
5615    }
5616
5617    pub fn alter_user_password_hash_with_epoch(
5618        &self,
5619        username: &str,
5620        hash: String,
5621    ) -> Result<Epoch> {
5622        self.alter_user_password_hash_with_epoch_inner(username, hash, None)
5623    }
5624
5625    pub fn alter_user_password_hash_with_epoch_controlled<F>(
5626        &self,
5627        username: &str,
5628        hash: String,
5629        mut before_publish: F,
5630    ) -> Result<Epoch>
5631    where
5632        F: FnMut() -> Result<()>,
5633    {
5634        self.alter_user_password_hash_with_epoch_inner(username, hash, Some(&mut before_publish))
5635    }
5636
5637    fn alter_user_password_hash_with_epoch_inner(
5638        &self,
5639        username: &str,
5640        hash: String,
5641        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5642    ) -> Result<Epoch> {
5643        self.require(&crate::auth::Permission::Admin)?;
5644        let _ddl = self.ddl_lock.lock();
5645        let _security_write = self.security_write()?;
5646        self.require(&crate::auth::Permission::Admin)?;
5647        let _commit = self.commit_lock.lock();
5648        let epoch = self.epoch.bump_assigned();
5649        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5650        let mut next_catalog = self.catalog.read().clone();
5651        let user = next_catalog
5652            .users
5653            .iter_mut()
5654            .find(|u| u.username == username)
5655            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5656        user.password_hash = hash;
5657        advance_security_version(&mut next_catalog)?;
5658        next_catalog.db_epoch = epoch.0;
5659        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5660        Ok(epoch)
5661    }
5662
5663    /// Verify credentials. Returns `Some(entry)` on success, `None` on
5664    /// mismatch, `Err` on engine error.
5665    pub fn verify_user(
5666        &self,
5667        username: &str,
5668        password: &str,
5669    ) -> Result<Option<crate::auth::UserEntry>> {
5670        let cat = self.catalog.read();
5671        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
5672            return Ok(None);
5673        };
5674        if user.password_hash.is_empty() {
5675            return Ok(None);
5676        }
5677        let ok = crate::auth::verify_password(password, &user.password_hash)
5678            .map_err(MongrelError::Other)?;
5679        if ok {
5680            Ok(Some(user.clone()))
5681        } else {
5682            Ok(None)
5683        }
5684    }
5685
5686    /// Authenticate and resolve one immutable principal from the same catalog
5687    /// snapshot. Username reuse cannot bridge the password check and principal
5688    /// resolution.
5689    pub fn authenticate_principal(
5690        &self,
5691        username: &str,
5692        password: &str,
5693    ) -> Result<Option<crate::auth::Principal>> {
5694        self.authenticate_principal_inner(username, password, || {})
5695    }
5696
5697    fn authenticate_principal_inner<F>(
5698        &self,
5699        username: &str,
5700        password: &str,
5701        after_verify: F,
5702    ) -> Result<Option<crate::auth::Principal>>
5703    where
5704        F: FnOnce(),
5705    {
5706        let catalog = self.catalog.read();
5707        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
5708            return Ok(None);
5709        };
5710        if user.password_hash.is_empty()
5711            || !crate::auth::verify_password(password, &user.password_hash)
5712                .map_err(MongrelError::Other)?
5713        {
5714            return Ok(None);
5715        }
5716        after_verify();
5717        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
5718    }
5719
5720    /// Grant admin privileges to a user (bypasses all permission checks).
5721    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
5722        self.set_user_admin_with_epoch(username, is_admin)
5723            .map(|_| ())
5724    }
5725
5726    pub fn set_user_admin_with_epoch(
5727        &self,
5728        username: &str,
5729        is_admin: bool,
5730    ) -> Result<Option<Epoch>> {
5731        self.set_user_admin_with_epoch_inner(username, is_admin, None)
5732    }
5733
5734    pub fn set_user_admin_with_epoch_controlled<F>(
5735        &self,
5736        username: &str,
5737        is_admin: bool,
5738        mut before_publish: F,
5739    ) -> Result<Option<Epoch>>
5740    where
5741        F: FnMut() -> Result<()>,
5742    {
5743        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
5744    }
5745
5746    fn set_user_admin_with_epoch_inner(
5747        &self,
5748        username: &str,
5749        is_admin: bool,
5750        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5751    ) -> Result<Option<Epoch>> {
5752        self.require(&crate::auth::Permission::Admin)?;
5753        let _ddl = self.ddl_lock.lock();
5754        let _security_write = self.security_write()?;
5755        self.require(&crate::auth::Permission::Admin)?;
5756        let _commit = self.commit_lock.lock();
5757        let mut next_catalog = self.catalog.read().clone();
5758        let user = next_catalog
5759            .users
5760            .iter_mut()
5761            .find(|u| u.username == username)
5762            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5763        if user.is_admin == is_admin {
5764            return Ok(None);
5765        }
5766        user.is_admin = is_admin;
5767        let epoch = self.epoch.bump_assigned();
5768        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5769        advance_security_version(&mut next_catalog)?;
5770        next_catalog.db_epoch = epoch.0;
5771        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5772        Ok(Some(epoch))
5773    }
5774
5775    /// Create a new role.
5776    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
5777        self.create_role_inner(name, None)
5778    }
5779
5780    pub fn create_role_controlled<F>(
5781        &self,
5782        name: &str,
5783        mut before_publish: F,
5784    ) -> Result<crate::auth::RoleEntry>
5785    where
5786        F: FnMut() -> Result<()>,
5787    {
5788        self.create_role_inner(name, Some(&mut before_publish))
5789    }
5790
5791    fn create_role_inner(
5792        &self,
5793        name: &str,
5794        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5795    ) -> Result<crate::auth::RoleEntry> {
5796        self.require(&crate::auth::Permission::Admin)?;
5797        let _ddl = self.ddl_lock.lock();
5798        let _security_write = self.security_write()?;
5799        self.require(&crate::auth::Permission::Admin)?;
5800        let _commit = self.commit_lock.lock();
5801        let epoch = self.epoch.bump_assigned();
5802        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5803        let mut next_catalog = self.catalog.read().clone();
5804        if next_catalog.roles.iter().any(|r| r.name == name) {
5805            return Err(MongrelError::InvalidArgument(format!(
5806                "role {name:?} already exists"
5807            )));
5808        }
5809        let entry = crate::auth::RoleEntry {
5810            name: name.into(),
5811            permissions: Vec::new(),
5812            created_epoch: epoch.0,
5813        };
5814        next_catalog.roles.push(entry.clone());
5815        advance_security_version(&mut next_catalog)?;
5816        next_catalog.db_epoch = epoch.0;
5817        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5818        Ok(entry)
5819    }
5820
5821    /// Drop a role by name.
5822    pub fn drop_role(&self, name: &str) -> Result<()> {
5823        self.drop_role_with_epoch(name).map(|_| ())
5824    }
5825
5826    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
5827        self.drop_role_with_epoch_inner(name, None)
5828    }
5829
5830    pub fn drop_role_with_epoch_controlled<F>(
5831        &self,
5832        name: &str,
5833        mut before_publish: F,
5834    ) -> Result<Epoch>
5835    where
5836        F: FnMut() -> Result<()>,
5837    {
5838        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
5839    }
5840
5841    fn drop_role_with_epoch_inner(
5842        &self,
5843        name: &str,
5844        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5845    ) -> Result<Epoch> {
5846        self.require(&crate::auth::Permission::Admin)?;
5847        let _ddl = self.ddl_lock.lock();
5848        let _security_write = self.security_write()?;
5849        self.require(&crate::auth::Permission::Admin)?;
5850        let _commit = self.commit_lock.lock();
5851        let epoch = self.epoch.bump_assigned();
5852        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5853        let mut next_catalog = self.catalog.read().clone();
5854        let before = next_catalog.roles.len();
5855        next_catalog.roles.retain(|r| r.name != name);
5856        if next_catalog.roles.len() == before {
5857            return Err(MongrelError::NotFound(format!("role {name:?} not found")));
5858        }
5859        for user in &mut next_catalog.users {
5860            user.roles.retain(|r| r != name);
5861        }
5862        advance_security_version(&mut next_catalog)?;
5863        next_catalog.db_epoch = epoch.0;
5864        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5865        Ok(epoch)
5866    }
5867
5868    /// Grant a role to a user.
5869    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
5870        self.grant_role_with_epoch(username, role_name).map(|_| ())
5871    }
5872
5873    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5874        self.grant_role_with_epoch_inner(username, role_name, None)
5875    }
5876
5877    pub fn grant_role_with_epoch_controlled<F>(
5878        &self,
5879        username: &str,
5880        role_name: &str,
5881        mut before_publish: F,
5882    ) -> Result<Option<Epoch>>
5883    where
5884        F: FnMut() -> Result<()>,
5885    {
5886        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5887    }
5888
5889    fn grant_role_with_epoch_inner(
5890        &self,
5891        username: &str,
5892        role_name: &str,
5893        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5894    ) -> Result<Option<Epoch>> {
5895        self.require(&crate::auth::Permission::Admin)?;
5896        let _ddl = self.ddl_lock.lock();
5897        let _security_write = self.security_write()?;
5898        self.require(&crate::auth::Permission::Admin)?;
5899        let _commit = self.commit_lock.lock();
5900        let mut next_catalog = self.catalog.read().clone();
5901        if !next_catalog.roles.iter().any(|r| r.name == role_name) {
5902            return Err(MongrelError::NotFound(format!(
5903                "role {role_name:?} not found"
5904            )));
5905        }
5906        let user = next_catalog
5907            .users
5908            .iter_mut()
5909            .find(|u| u.username == username)
5910            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5911        if user.roles.iter().any(|role| role == role_name) {
5912            return Ok(None);
5913        }
5914        user.roles.push(role_name.into());
5915        let epoch = self.epoch.bump_assigned();
5916        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5917        advance_security_version(&mut next_catalog)?;
5918        next_catalog.db_epoch = epoch.0;
5919        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5920        Ok(Some(epoch))
5921    }
5922
5923    /// Revoke a role from a user.
5924    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
5925        self.revoke_role_with_epoch(username, role_name).map(|_| ())
5926    }
5927
5928    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5929        self.revoke_role_with_epoch_inner(username, role_name, None)
5930    }
5931
5932    pub fn revoke_role_with_epoch_controlled<F>(
5933        &self,
5934        username: &str,
5935        role_name: &str,
5936        mut before_publish: F,
5937    ) -> Result<Option<Epoch>>
5938    where
5939        F: FnMut() -> Result<()>,
5940    {
5941        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5942    }
5943
5944    fn revoke_role_with_epoch_inner(
5945        &self,
5946        username: &str,
5947        role_name: &str,
5948        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5949    ) -> Result<Option<Epoch>> {
5950        self.require(&crate::auth::Permission::Admin)?;
5951        let _ddl = self.ddl_lock.lock();
5952        let _security_write = self.security_write()?;
5953        self.require(&crate::auth::Permission::Admin)?;
5954        let _commit = self.commit_lock.lock();
5955        let mut next_catalog = self.catalog.read().clone();
5956        let user = next_catalog
5957            .users
5958            .iter_mut()
5959            .find(|u| u.username == username)
5960            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5961        let before = user.roles.len();
5962        user.roles.retain(|r| r != role_name);
5963        if user.roles.len() == before {
5964            return Ok(None);
5965        }
5966        let epoch = self.epoch.bump_assigned();
5967        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5968        advance_security_version(&mut next_catalog)?;
5969        next_catalog.db_epoch = epoch.0;
5970        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5971        Ok(Some(epoch))
5972    }
5973
5974    /// Grant a permission to a role.
5975    pub fn grant_permission(
5976        &self,
5977        role_name: &str,
5978        permission: crate::auth::Permission,
5979    ) -> Result<()> {
5980        self.grant_permission_with_epoch(role_name, permission)
5981            .map(|_| ())
5982    }
5983
5984    pub fn grant_permission_with_epoch(
5985        &self,
5986        role_name: &str,
5987        permission: crate::auth::Permission,
5988    ) -> Result<Option<Epoch>> {
5989        self.grant_permission_with_epoch_inner(role_name, permission, None)
5990    }
5991
5992    pub fn grant_permission_with_epoch_controlled<F>(
5993        &self,
5994        role_name: &str,
5995        permission: crate::auth::Permission,
5996        mut before_publish: F,
5997    ) -> Result<Option<Epoch>>
5998    where
5999        F: FnMut() -> Result<()>,
6000    {
6001        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
6002    }
6003
6004    fn grant_permission_with_epoch_inner(
6005        &self,
6006        role_name: &str,
6007        permission: crate::auth::Permission,
6008        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6009    ) -> Result<Option<Epoch>> {
6010        self.require(&crate::auth::Permission::Admin)?;
6011        let _ddl = self.ddl_lock.lock();
6012        let _security_write = self.security_write()?;
6013        self.require(&crate::auth::Permission::Admin)?;
6014        let _commit = self.commit_lock.lock();
6015        let mut next_catalog = self.catalog.read().clone();
6016        let role = next_catalog
6017            .roles
6018            .iter_mut()
6019            .find(|r| r.name == role_name)
6020            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
6021        let before = role.permissions.clone();
6022        merge_permission(&mut role.permissions, permission);
6023        if role.permissions == before {
6024            return Ok(None);
6025        }
6026        let epoch = self.epoch.bump_assigned();
6027        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6028        advance_security_version(&mut next_catalog)?;
6029        next_catalog.db_epoch = epoch.0;
6030        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6031        Ok(Some(epoch))
6032    }
6033
6034    /// Revoke a permission from a role.
6035    pub fn revoke_permission(
6036        &self,
6037        role_name: &str,
6038        permission: crate::auth::Permission,
6039    ) -> Result<()> {
6040        self.revoke_permission_with_epoch(role_name, permission)
6041            .map(|_| ())
6042    }
6043
6044    pub fn revoke_permission_with_epoch(
6045        &self,
6046        role_name: &str,
6047        permission: crate::auth::Permission,
6048    ) -> Result<Option<Epoch>> {
6049        self.revoke_permission_with_epoch_inner(role_name, permission, None)
6050    }
6051
6052    pub fn revoke_permission_with_epoch_controlled<F>(
6053        &self,
6054        role_name: &str,
6055        permission: crate::auth::Permission,
6056        mut before_publish: F,
6057    ) -> Result<Option<Epoch>>
6058    where
6059        F: FnMut() -> Result<()>,
6060    {
6061        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
6062    }
6063
6064    fn revoke_permission_with_epoch_inner(
6065        &self,
6066        role_name: &str,
6067        permission: crate::auth::Permission,
6068        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6069    ) -> Result<Option<Epoch>> {
6070        self.require(&crate::auth::Permission::Admin)?;
6071        let _ddl = self.ddl_lock.lock();
6072        let _security_write = self.security_write()?;
6073        self.require(&crate::auth::Permission::Admin)?;
6074        let _commit = self.commit_lock.lock();
6075        let mut next_catalog = self.catalog.read().clone();
6076        let role = next_catalog
6077            .roles
6078            .iter_mut()
6079            .find(|r| r.name == role_name)
6080            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
6081        let before = role.permissions.clone();
6082        revoke_permission_from(&mut role.permissions, &permission);
6083        if role.permissions == before {
6084            return Ok(None);
6085        }
6086        let epoch = self.epoch.bump_assigned();
6087        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6088        advance_security_version(&mut next_catalog)?;
6089        next_catalog.db_epoch = epoch.0;
6090        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6091        Ok(Some(epoch))
6092    }
6093
6094    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
6095    /// permissions from their roles. Returns `None` if the user doesn't exist.
6096    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
6097        let cat = self.catalog.read();
6098        Self::resolve_principal_from_catalog(&cat, username)
6099    }
6100
6101    /// Re-resolve only when the immutable user identity still exists. This is
6102    /// the server/session validation path; username reuse never matches.
6103    pub fn resolve_current_principal(
6104        &self,
6105        principal: &crate::auth::Principal,
6106    ) -> Option<crate::auth::Principal> {
6107        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
6108    }
6109
6110    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
6111    /// without needing a constructed `Database`. Used by the credentialed open
6112    /// path (which must verify credentials before the `Database` exists) and
6113    /// by [`resolve_principal`](Self::resolve_principal).
6114    fn resolve_principal_from_catalog(
6115        cat: &Catalog,
6116        username: &str,
6117    ) -> Option<crate::auth::Principal> {
6118        let user = cat.users.iter().find(|u| u.username == username)?;
6119        Self::resolve_user_principal_from_catalog(cat, user)
6120    }
6121
6122    fn resolve_bound_principal_from_catalog(
6123        cat: &Catalog,
6124        principal: &crate::auth::Principal,
6125    ) -> Option<crate::auth::Principal> {
6126        let user = cat.users.iter().find(|user| {
6127            user.id == principal.user_id
6128                && user.created_epoch == principal.created_epoch
6129                && user.username == principal.username
6130        })?;
6131        Self::resolve_user_principal_from_catalog(cat, user)
6132    }
6133
6134    fn resolve_user_principal_from_catalog(
6135        cat: &Catalog,
6136        user: &crate::auth::UserEntry,
6137    ) -> Option<crate::auth::Principal> {
6138        let mut permissions = Vec::new();
6139        for role_name in &user.roles {
6140            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
6141                permissions.extend(role.permissions.iter().cloned());
6142            }
6143        }
6144        Some(crate::auth::Principal {
6145            user_id: user.id,
6146            created_epoch: user.created_epoch,
6147            username: user.username.clone(),
6148            is_admin: user.is_admin,
6149            roles: user.roles.clone(),
6150            permissions,
6151        })
6152    }
6153
6154    /// Check whether a user has a specific permission (via their roles).
6155    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
6156        match self.resolve_principal(username) {
6157            Some(p) => p.has_permission(permission),
6158            None => false,
6159        }
6160    }
6161
6162    /// Returns `true` if this database's catalog has `require_auth = true`.
6163    /// When true, every operation consults the cached [`Principal`] via
6164    /// [`require`](Self::require).
6165    pub fn require_auth_enabled(&self) -> bool {
6166        self.catalog.read().require_auth
6167    }
6168
6169    /// A snapshot of the cached principal for this handle, if any. `None` for
6170    /// databases opened without credentials (the default). Returns a clone
6171    /// because the principal lives behind an `RwLock`.
6172    pub fn principal(&self) -> Option<crate::auth::Principal> {
6173        self.principal.read().clone()
6174    }
6175
6176    /// Build a `TableAuthChecker` from the current auth state. Used when
6177    /// mounting a new table (`create_table`) so the table inherits the
6178    /// database's enforcement configuration. The checker reads the live
6179    /// `require_auth` flag and cached principal, so changes via `enable_auth`
6180    /// / `refresh_principal` propagate to already-mounted tables.
6181    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
6182        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
6183            self.auth_state.clone(),
6184        )))
6185    }
6186
6187    /// Re-resolve the cached principal from the shared current catalog.
6188    /// Long-lived
6189    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
6190    /// possibly made by a different handle to the same database — to pick up
6191    /// the new effective permissions without re-verifying the password.
6192    ///
6193    /// The process-wide security version reloads from disk only when another
6194    /// handle published a newer catalog. The username is taken from
6195    /// the existing cached principal; if the user has since been dropped,
6196    /// returns [`MongrelError::InvalidCredentials`].
6197    ///
6198    /// No-op (returns `Ok(())`) on a credentialless database, or on a
6199    /// credentialed database whose cached principal is `None`.
6200    pub fn refresh_principal(&self) -> Result<()> {
6201        let previous = match self.principal.read().clone() {
6202            Some(principal) => principal,
6203            None => return Ok(()),
6204        };
6205        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
6206        self.refresh_security_catalog_if_stale(observed_version)?;
6207        let cat = self.catalog.read();
6208        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
6209            Some(p) => {
6210                *self.principal.write() = Some(p.clone());
6211                // Update the shared auth state so mounted Tables see the new
6212                // permissions immediately (Tables read from AuthState, not from
6213                // self.principal).
6214                self.auth_state.set_principal(Some(p));
6215                Ok(())
6216            }
6217            None => Err(MongrelError::InvalidCredentials {
6218                username: previous.username,
6219            }),
6220        }
6221    }
6222
6223    /// Number of security-catalog disk reloads performed by this open handle.
6224    /// Initial open reads are excluded.
6225    pub fn security_catalog_disk_read_count(&self) -> u64 {
6226        self.security_catalog_disk_reads.load(Ordering::Relaxed)
6227    }
6228
6229    /// Convert a credentialless database to a credentialed one: create the
6230    /// first admin user, set `require_auth = true`, and cache the admin
6231    /// principal on this handle so subsequent operations on the same handle
6232    /// continue to work. After this call, the database can only be reopened
6233    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
6234    ///
6235    /// Refuses if the database already has `require_auth = true`. This is
6236    /// the conversion path for existing databases; for fresh databases,
6237    /// `create_with_credentials` sets everything up atomically.
6238    ///
6239    /// See `docs/15-credential-enforcement.md`.
6240    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
6241        let password_hash =
6242            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
6243        let _ddl = self.ddl_lock.lock();
6244        let _security_write = self.security_write()?;
6245        let _commit = self.commit_lock.lock();
6246        let epoch = self.epoch.bump_assigned();
6247        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6248        let mut next_catalog = self.catalog.read().clone();
6249        if next_catalog.require_auth {
6250            return Err(MongrelError::InvalidArgument(
6251                "database already has require_auth enabled".into(),
6252            ));
6253        }
6254        if next_catalog
6255            .users
6256            .iter()
6257            .any(|u| u.username == admin_username)
6258        {
6259            return Err(MongrelError::InvalidArgument(format!(
6260                "user {admin_username:?} already exists"
6261            )));
6262        }
6263        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
6264        let id = next_catalog.next_user_id;
6265        next_catalog.next_user_id = id
6266            .checked_add(1)
6267            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
6268        next_catalog.users.push(crate::auth::UserEntry {
6269            id,
6270            username: admin_username.to_string(),
6271            password_hash,
6272            roles: Vec::new(),
6273            is_admin: true,
6274            created_epoch: epoch.0,
6275        });
6276        next_catalog.require_auth = true;
6277        advance_security_version(&mut next_catalog)?;
6278        next_catalog.db_epoch = epoch.0;
6279        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6280        // Cache the admin principal on this handle + update the shared auth
6281        // state whenever rename published, even if directory fsync was
6282        // inconclusive.
6283        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6284            let principal = crate::auth::Principal {
6285                user_id: id,
6286                created_epoch: epoch.0,
6287                username: admin_username.to_string(),
6288                is_admin: true,
6289                roles: Vec::new(),
6290                permissions: Vec::new(),
6291            };
6292            *self.principal.write() = Some(principal.clone());
6293            self.auth_state.set_principal(Some(principal));
6294        }
6295        publish
6296    }
6297
6298    /// Disable `require_auth` on this database, reverting it to credentialless
6299    /// mode. This is the **recovery** path — it requires the handle to already
6300    /// be open (and therefore already authenticated if `require_auth` was on).
6301    ///
6302    /// After this call, the database can be reopened with plain
6303    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
6304    /// credentials. All existing users and roles are preserved in the catalog
6305    /// (so `require_auth` can be re-enabled without recreating them), but they
6306    /// are no longer consulted for enforcement.
6307    ///
6308    /// For true **offline** recovery (when credentials are lost and no
6309    /// authenticated handle is available), the caller opens the database
6310    /// directly via the catalog file (filesystem access required) and calls
6311    /// this method — see the CLI's `auth disable-offline` command.
6312    ///
6313    /// See `docs/15-credential-enforcement.md` §4.7.
6314    pub fn disable_auth(&self) -> Result<()> {
6315        let _ddl = self.ddl_lock.lock();
6316        let _security_write = self.security_write()?;
6317        let _commit = self.commit_lock.lock();
6318        let epoch = self.epoch.bump_assigned();
6319        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6320        let mut next_catalog = self.catalog.read().clone();
6321        if !next_catalog.require_auth {
6322            return Err(MongrelError::InvalidArgument(
6323                "database does not have require_auth enabled".into(),
6324            ));
6325        }
6326        next_catalog.require_auth = false;
6327        advance_security_version(&mut next_catalog)?;
6328        next_catalog.db_epoch = epoch.0;
6329        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6330        // Clear the cached principal — enforcement is now off.
6331        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6332            *self.principal.write() = None;
6333        }
6334        publish
6335    }
6336
6337    /// Enforcement check: if the catalog has `require_auth = true`, verify
6338    /// that the cached principal satisfies `perm`. Called by every
6339    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
6340    /// Table/Transaction/MongrelSession operations).
6341    ///
6342    /// On a credentialless database this is a no-op (`Ok(())`).
6343    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
6344        self.ensure_owner_process()?;
6345        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
6346            return Err(MongrelError::ReadOnlyReplica);
6347        }
6348        if self.principal.read().is_some() {
6349            self.refresh_principal().map_err(|error| match error {
6350                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
6351                error => error,
6352            })?;
6353        }
6354        if !self.catalog.read().require_auth {
6355            return Ok(());
6356        }
6357        let guard = self.principal.read();
6358        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
6359        if p.has_permission(perm) {
6360            Ok(())
6361        } else {
6362            Err(MongrelError::PermissionDenied {
6363                required: perm.clone(),
6364                principal: p.username.clone(),
6365            })
6366        }
6367    }
6368
6369    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
6370    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
6371    /// other callers that know the operation kind + table name but don't want
6372    /// to construct the full `Permission` enum value themselves.
6373    pub fn require_table(
6374        &self,
6375        table: &str,
6376        perm: crate::auth_state::RequiredPermission,
6377    ) -> Result<()> {
6378        self.require(&perm.into_permission(table))
6379    }
6380
6381    pub fn triggers(&self) -> Vec<StoredTrigger> {
6382        self.catalog
6383            .read()
6384            .triggers
6385            .iter()
6386            .map(|t| t.trigger.clone())
6387            .collect()
6388    }
6389
6390    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
6391        self.catalog
6392            .read()
6393            .triggers
6394            .iter()
6395            .find(|t| t.trigger.name == name)
6396            .map(|t| t.trigger.clone())
6397    }
6398
6399    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6400        self.create_trigger_inner(trigger, None, None)
6401    }
6402
6403    pub fn create_trigger_controlled<F>(
6404        &self,
6405        trigger: StoredTrigger,
6406        mut before_publish: F,
6407    ) -> Result<StoredTrigger>
6408    where
6409        F: FnMut() -> Result<()>,
6410    {
6411        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
6412    }
6413
6414    pub fn create_trigger_as_controlled<F>(
6415        &self,
6416        trigger: StoredTrigger,
6417        principal: Option<&crate::auth::Principal>,
6418        mut before_publish: F,
6419    ) -> Result<StoredTrigger>
6420    where
6421        F: FnMut() -> Result<()>,
6422    {
6423        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
6424    }
6425
6426    fn create_trigger_inner(
6427        &self,
6428        mut trigger: StoredTrigger,
6429        principal: Option<&crate::auth::Principal>,
6430        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6431    ) -> Result<StoredTrigger> {
6432        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6433        let _g = self.ddl_lock.lock();
6434        let _security_write = self.security_write()?;
6435        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6436        trigger.validate()?;
6437        self.validate_trigger_references(&trigger)
6438            .map_err(trigger_validation_error)?;
6439        {
6440            let cat = self.catalog.read();
6441            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
6442                return Err(MongrelError::TriggerValidation(format!(
6443                    "trigger {:?} already exists",
6444                    trigger.name
6445                )));
6446            }
6447        }
6448        let commit_lock = Arc::clone(&self.commit_lock);
6449        let _c = commit_lock.lock();
6450        let epoch = self.epoch.bump_assigned();
6451        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6452        trigger.created_epoch = epoch.0;
6453        trigger.updated_epoch = epoch.0;
6454        let mut next_catalog = self.catalog.read().clone();
6455        next_catalog
6456            .triggers
6457            .push(TriggerEntry::from(trigger.clone()));
6458        next_catalog.db_epoch = epoch.0;
6459        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6460        Ok(trigger)
6461    }
6462
6463    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6464        self.create_or_replace_trigger_inner(trigger, None, None)
6465    }
6466
6467    pub fn create_or_replace_trigger_controlled<F>(
6468        &self,
6469        trigger: StoredTrigger,
6470        mut before_publish: F,
6471    ) -> Result<StoredTrigger>
6472    where
6473        F: FnMut() -> Result<()>,
6474    {
6475        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
6476    }
6477
6478    pub fn create_or_replace_trigger_as_controlled<F>(
6479        &self,
6480        trigger: StoredTrigger,
6481        principal: Option<&crate::auth::Principal>,
6482        mut before_publish: F,
6483    ) -> Result<StoredTrigger>
6484    where
6485        F: FnMut() -> Result<()>,
6486    {
6487        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
6488    }
6489
6490    fn create_or_replace_trigger_inner(
6491        &self,
6492        trigger: StoredTrigger,
6493        principal: Option<&crate::auth::Principal>,
6494        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6495    ) -> Result<StoredTrigger> {
6496        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6497        let _g = self.ddl_lock.lock();
6498        let _security_write = self.security_write()?;
6499        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6500        trigger.validate()?;
6501        self.validate_trigger_references(&trigger)
6502            .map_err(trigger_validation_error)?;
6503        let commit_lock = Arc::clone(&self.commit_lock);
6504        let _c = commit_lock.lock();
6505        let epoch = self.epoch.bump_assigned();
6506        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6507        let mut next_catalog = self.catalog.read().clone();
6508        let replaced = {
6509            let next = match next_catalog
6510                .triggers
6511                .iter()
6512                .position(|t| t.trigger.name == trigger.name)
6513            {
6514                Some(idx) => {
6515                    let next = next_catalog.triggers[idx]
6516                        .trigger
6517                        .replaced(trigger.clone(), epoch.0)?;
6518                    next_catalog.triggers[idx] = TriggerEntry::from(next.clone());
6519                    next
6520                }
6521                None => {
6522                    let mut next = trigger;
6523                    next.created_epoch = epoch.0;
6524                    next.updated_epoch = epoch.0;
6525                    next_catalog.triggers.push(TriggerEntry::from(next.clone()));
6526                    next
6527                }
6528            };
6529            next_catalog.db_epoch = epoch.0;
6530            next
6531        };
6532        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6533        Ok(replaced)
6534    }
6535
6536    pub fn drop_trigger(&self, name: &str) -> Result<()> {
6537        self.drop_trigger_with_epoch(name).map(|_| ())
6538    }
6539
6540    /// Drop one trigger and return the exact catalog publication epoch.
6541    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
6542        self.drop_triggers_with_epoch(&[name.to_string()])
6543    }
6544
6545    pub fn drop_trigger_with_epoch_controlled<F>(
6546        &self,
6547        name: &str,
6548        before_publish: F,
6549    ) -> Result<Epoch>
6550    where
6551        F: FnMut() -> Result<()>,
6552    {
6553        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
6554    }
6555
6556    /// Atomically drop several triggers in one catalog publication.
6557    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
6558        self.drop_triggers_with_epoch_inner(names, None, None)
6559    }
6560
6561    pub fn drop_triggers_with_epoch_controlled<F>(
6562        &self,
6563        names: &[String],
6564        mut before_publish: F,
6565    ) -> Result<Epoch>
6566    where
6567        F: FnMut() -> Result<()>,
6568    {
6569        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
6570    }
6571
6572    pub fn drop_triggers_with_epoch_as_controlled<F>(
6573        &self,
6574        names: &[String],
6575        principal: Option<&crate::auth::Principal>,
6576        mut before_publish: F,
6577    ) -> Result<Epoch>
6578    where
6579        F: FnMut() -> Result<()>,
6580    {
6581        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
6582    }
6583
6584    fn drop_triggers_with_epoch_inner(
6585        &self,
6586        names: &[String],
6587        principal: Option<&crate::auth::Principal>,
6588        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6589    ) -> Result<Epoch> {
6590        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6591        if names.is_empty() {
6592            return Err(MongrelError::InvalidArgument(
6593                "at least one trigger name is required".into(),
6594            ));
6595        }
6596        let _g = self.ddl_lock.lock();
6597        let _security_write = self.security_write()?;
6598        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6599        {
6600            let cat = self.catalog.read();
6601            for name in names {
6602                if !cat.triggers.iter().any(|t| t.trigger.name == *name) {
6603                    return Err(MongrelError::NotFound(format!(
6604                        "trigger {name:?} not found"
6605                    )));
6606                }
6607            }
6608        }
6609        let commit_lock = Arc::clone(&self.commit_lock);
6610        let _c = commit_lock.lock();
6611        let epoch = self.epoch.bump_assigned();
6612        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6613        let mut next_catalog = self.catalog.read().clone();
6614        next_catalog
6615            .triggers
6616            .retain(|trigger| !names.contains(&trigger.trigger.name));
6617        next_catalog.db_epoch = epoch.0;
6618        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6619        Ok(epoch)
6620    }
6621
6622    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
6623        self.catalog.read().external_tables.clone()
6624    }
6625
6626    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
6627        self.catalog
6628            .read()
6629            .external_tables
6630            .iter()
6631            .find(|t| t.name == name)
6632            .cloned()
6633    }
6634
6635    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
6636        self.create_external_table_inner(entry, None)
6637    }
6638
6639    pub fn create_external_table_controlled<F>(
6640        &self,
6641        entry: ExternalTableEntry,
6642        mut before_publish: F,
6643    ) -> Result<ExternalTableEntry>
6644    where
6645        F: FnMut() -> Result<()>,
6646    {
6647        self.create_external_table_inner(entry, Some(&mut before_publish))
6648    }
6649
6650    fn create_external_table_inner(
6651        &self,
6652        mut entry: ExternalTableEntry,
6653        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6654    ) -> Result<ExternalTableEntry> {
6655        self.require(&crate::auth::Permission::Ddl)?;
6656        let _g = self.ddl_lock.lock();
6657        let _security_write = self.security_write()?;
6658        self.require(&crate::auth::Permission::Ddl)?;
6659        entry.validate()?;
6660        {
6661            let cat = self.catalog.read();
6662            if cat.live(&entry.name).is_some()
6663                || cat.external_tables.iter().any(|t| t.name == entry.name)
6664            {
6665                return Err(MongrelError::InvalidArgument(format!(
6666                    "table {:?} already exists",
6667                    entry.name
6668                )));
6669            }
6670        }
6671        let commit_lock = Arc::clone(&self.commit_lock);
6672        let _c = commit_lock.lock();
6673        // A prior durable drop may have left connector state behind if its
6674        // cleanup failed or the process crashed. Never let a new table with
6675        // the same name inherit that stale state.
6676        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
6677        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
6678        let epoch = self.epoch.bump_assigned();
6679        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6680        entry.created_epoch = epoch.0;
6681        let mut next_catalog = self.catalog.read().clone();
6682        next_catalog.external_tables.push(entry.clone());
6683        next_catalog.db_epoch = epoch.0;
6684        self.publish_catalog_candidate_with_prelude(
6685            next_catalog,
6686            epoch,
6687            &mut _epoch_guard,
6688            before_publish,
6689            vec![(
6690                EXTERNAL_TABLE_ID,
6691                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6692                    name: entry.name.clone(),
6693                    generation_epoch: epoch.0,
6694                }),
6695            )],
6696        )?;
6697        Ok(entry)
6698    }
6699
6700    pub fn drop_external_table(&self, name: &str) -> Result<()> {
6701        self.drop_external_table_with_epoch(name).map(|_| ())
6702    }
6703
6704    /// Drop an external table and return the exact catalog publication epoch.
6705    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
6706        self.drop_external_table_with_epoch_inner(name, None)
6707    }
6708
6709    pub fn drop_external_table_with_epoch_controlled<F>(
6710        &self,
6711        name: &str,
6712        mut before_publish: F,
6713    ) -> Result<Epoch>
6714    where
6715        F: FnMut() -> Result<()>,
6716    {
6717        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
6718    }
6719
6720    fn drop_external_table_with_epoch_inner(
6721        &self,
6722        name: &str,
6723        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6724    ) -> Result<Epoch> {
6725        self.require(&crate::auth::Permission::Ddl)?;
6726        let _g = self.ddl_lock.lock();
6727        let _security_write = self.security_write()?;
6728        self.require(&crate::auth::Permission::Ddl)?;
6729        let commit_lock = Arc::clone(&self.commit_lock);
6730        let _c = commit_lock.lock();
6731        let epoch = self.epoch.bump_assigned();
6732        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6733        let mut next_catalog = self.catalog.read().clone();
6734        let before = next_catalog.external_tables.len();
6735        next_catalog.external_tables.retain(|t| t.name != name);
6736        if next_catalog.external_tables.len() == before {
6737            return Err(MongrelError::NotFound(format!(
6738                "external table {name:?} not found"
6739            )));
6740        }
6741        next_catalog.db_epoch = epoch.0;
6742        self.publish_catalog_candidate_with_prelude(
6743            next_catalog,
6744            epoch,
6745            &mut _epoch_guard,
6746            before_publish,
6747            vec![(
6748                EXTERNAL_TABLE_ID,
6749                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6750                    name: name.to_string(),
6751                    generation_epoch: epoch.0,
6752                }),
6753            )],
6754        )?;
6755        let state_dir = self.root.join(VTAB_DIR).join(name);
6756        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
6757            return Err(MongrelError::DurableCommit {
6758                epoch: epoch.0,
6759                message: format!(
6760                    "external table was dropped but connector-state cleanup failed: {error}"
6761                ),
6762            });
6763        }
6764        Ok(epoch)
6765    }
6766
6767    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
6768        let txn_id = self.alloc_txn_id()?;
6769        let (principal, catalog_bound) = self.transaction_principal_snapshot();
6770        self.commit_transaction_with_external_states(
6771            txn_id,
6772            self.epoch.visible(),
6773            Vec::new(),
6774            vec![(name.to_string(), state.to_vec())],
6775            Vec::new(),
6776            principal,
6777            catalog_bound,
6778            None,
6779        )
6780        .map(|(epoch, _)| epoch)
6781    }
6782
6783    pub fn trigger_config(&self) -> TriggerConfig {
6784        use std::sync::atomic::Ordering;
6785        TriggerConfig {
6786            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
6787            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
6788            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
6789        }
6790    }
6791
6792    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
6793        use std::sync::atomic::Ordering;
6794        if config.max_depth == 0 {
6795            return Err(MongrelError::InvalidArgument(
6796                "trigger max_depth must be greater than 0".into(),
6797            ));
6798        }
6799        self.trigger_recursive
6800            .store(config.recursive_triggers, Ordering::Relaxed);
6801        self.trigger_max_depth
6802            .store(config.max_depth, Ordering::Relaxed);
6803        self.trigger_max_loop_iterations
6804            .store(config.max_loop_iterations, Ordering::Relaxed);
6805        Ok(())
6806    }
6807
6808    pub fn set_recursive_triggers(&self, recursive: bool) {
6809        use std::sync::atomic::Ordering;
6810        self.trigger_recursive.store(recursive, Ordering::Relaxed);
6811    }
6812
6813    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
6814    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
6815    /// as a low-latency wake-up.
6816    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
6817        self.notify.subscribe()
6818    }
6819
6820    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
6821        self.change_wake.subscribe()
6822    }
6823
6824    /// Reconstruct committed row changes from the retained shared WAL. Event
6825    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
6826    /// resumes before the oldest retained commit receives `gap = true` and
6827    /// must rebootstrap instead of silently skipping changes.
6828    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
6829        let control = crate::ExecutionControl::new(None);
6830        self.change_events_since_controlled(last_event_id, &control)
6831    }
6832
6833    /// Reconstruct committed changes with cooperative cancellation and bounds.
6834    pub fn change_events_since_controlled(
6835        &self,
6836        last_event_id: Option<&str>,
6837        control: &crate::ExecutionControl,
6838    ) -> Result<CdcBatch> {
6839        use crate::wal::Op;
6840
6841        control.checkpoint()?;
6842        let resume = match last_event_id {
6843            Some(id) => {
6844                let (epoch, index) = id.split_once(':').ok_or_else(|| {
6845                    MongrelError::InvalidArgument(format!(
6846                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
6847                    ))
6848                })?;
6849                Some((
6850                    epoch.parse::<u64>().map_err(|error| {
6851                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
6852                    })?,
6853                    index.parse::<u32>().map_err(|error| {
6854                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
6855                    })?,
6856                ))
6857            }
6858            None => None,
6859        };
6860
6861        let mut wal = self.shared_wal.lock();
6862        wal.group_sync()?;
6863        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6864        let records = crate::wal::SharedWal::replay_with_dek_controlled(
6865            &self.root,
6866            wal_dek.as_ref(),
6867            control,
6868            CDC_MAX_WAL_RECORDS,
6869            CDC_MAX_WAL_REPLAY_BYTES,
6870        )?;
6871        drop(wal);
6872        control.checkpoint()?;
6873
6874        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
6875        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
6876        for (index, record) in records.iter().enumerate() {
6877            if index % 256 == 0 {
6878                control.checkpoint()?;
6879            }
6880            if let Op::TxnCommit { epoch, added_runs } = &record.op {
6881                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
6882            }
6883            if let Op::SpilledRows { table_id, rows } = &record.op {
6884                spilled_payloads
6885                    .entry((record.txn_id, *table_id))
6886                    .or_default()
6887                    .push(rows);
6888            }
6889        }
6890        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
6891        let current_epoch = self.epoch.committed().0;
6892        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
6893        let gap = resume.is_some_and(|(epoch, _)| {
6894            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
6895        });
6896        if gap {
6897            return Ok(CdcBatch {
6898                events: Vec::new(),
6899                current_epoch,
6900                earliest_epoch,
6901                gap: true,
6902            });
6903        }
6904
6905        let table_names: HashMap<u64, String> = self
6906            .catalog
6907            .read()
6908            .tables
6909            .iter()
6910            .map(|entry| (entry.table_id, entry.name.clone()))
6911            .collect();
6912        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
6913        let mut retained_bytes = 0_usize;
6914        for (index, record) in records.iter().enumerate() {
6915            if index % 256 == 0 {
6916                control.checkpoint()?;
6917            }
6918            if !commits.contains_key(&record.txn_id) {
6919                continue;
6920            }
6921            let Op::BeforeImage {
6922                table_id,
6923                row_id,
6924                row,
6925            } = &record.op
6926            else {
6927                continue;
6928            };
6929            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6930                return Err(MongrelError::ResourceLimitExceeded {
6931                    resource: "CDC before-image bytes",
6932                    requested: row.len(),
6933                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6934                });
6935            }
6936            let before: crate::memtable::Row = bincode::deserialize(row)?;
6937            if before_images.len() >= CDC_MAX_ROWS {
6938                return Err(MongrelError::ResourceLimitExceeded {
6939                    resource: "CDC before-image rows",
6940                    requested: before_images.len().saturating_add(1),
6941                    limit: CDC_MAX_ROWS,
6942                });
6943            }
6944            charge_cdc_bytes(
6945                &mut retained_bytes,
6946                cdc_row_storage_bytes(&before),
6947                "CDC retained bytes",
6948            )?;
6949            before_images.insert((record.txn_id, *table_id, row_id.0), before);
6950        }
6951        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
6952        let mut events = Vec::new();
6953        let mut decoded_rows = before_images.len();
6954        for (record_index, record) in records.iter().enumerate() {
6955            if record_index % 256 == 0 {
6956                control.checkpoint()?;
6957            }
6958            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
6959                continue;
6960            };
6961            let event = match &record.op {
6962                Op::Put { table_id, rows } => {
6963                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6964                        return Err(MongrelError::ResourceLimitExceeded {
6965                            resource: "CDC inline row bytes",
6966                            requested: rows.len(),
6967                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6968                        });
6969                    }
6970                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
6971                    decoded_rows = decoded_rows.saturating_add(rows.len());
6972                    if decoded_rows > CDC_MAX_ROWS {
6973                        return Err(MongrelError::ResourceLimitExceeded {
6974                            resource: "CDC decoded rows",
6975                            requested: decoded_rows,
6976                            limit: CDC_MAX_ROWS,
6977                        });
6978                    }
6979                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
6980                    let mut peak_bytes = retained_bytes;
6981                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6982                    let data = serde_json::to_value(rows)
6983                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
6984                    Some((*table_id, "put", data, event_bytes))
6985                }
6986                Op::Delete { table_id, row_ids } => {
6987                    let before = row_ids
6988                        .iter()
6989                        .filter_map(|row_id| {
6990                            before_images
6991                                .get(&(record.txn_id, *table_id, row_id.0))
6992                                .cloned()
6993                        })
6994                        .collect::<Vec<_>>();
6995                    let event_bytes = cdc_rows_json_bytes(&before)
6996                        .saturating_add(
6997                            row_ids
6998                                .len()
6999                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
7000                        )
7001                        .saturating_add(512);
7002                    let mut peak_bytes = retained_bytes;
7003                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
7004                    Some((
7005                        *table_id,
7006                        "delete",
7007                        serde_json::json!({
7008                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
7009                            "before": before,
7010                        }),
7011                        event_bytes,
7012                    ))
7013                }
7014                Op::TruncateTable { table_id } => {
7015                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
7016                }
7017                _ => None,
7018            };
7019            if let Some((table_id, op, data, event_bytes)) = event {
7020                let index = operation_indices.entry(record.txn_id).or_insert(0);
7021                let event_position = (*commit_epoch, *index);
7022                *index = index.saturating_add(1);
7023                if resume.is_some_and(|position| event_position <= position) {
7024                    continue;
7025                }
7026                if events.len() >= CDC_MAX_EVENTS {
7027                    return Err(MongrelError::ResourceLimitExceeded {
7028                        resource: "CDC events",
7029                        requested: events.len().saturating_add(1),
7030                        limit: CDC_MAX_EVENTS,
7031                    });
7032                }
7033                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
7034                events.push(ChangeEvent {
7035                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
7036                    channel: "changes".into(),
7037                    table_id: Some(table_id),
7038                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
7039                    op: op.into(),
7040                    epoch: *commit_epoch,
7041                    txn_id: Some(record.txn_id),
7042                    message: None,
7043                    data: Some(data),
7044                });
7045            }
7046            if let Op::TxnCommit { added_runs, .. } = &record.op {
7047                for run in added_runs {
7048                    control.checkpoint()?;
7049                    let index = operation_indices.entry(record.txn_id).or_insert(0);
7050                    let event_position = (*commit_epoch, *index);
7051                    *index = index.saturating_add(1);
7052                    if resume.is_some_and(|position| event_position <= position) {
7053                        continue;
7054                    }
7055                    let mut rows = if let Some(payloads) =
7056                        spilled_payloads.get(&(record.txn_id, run.table_id))
7057                    {
7058                        let mut rows = Vec::new();
7059                        for payload in payloads {
7060                            control.checkpoint()?;
7061                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
7062                                return Err(MongrelError::ResourceLimitExceeded {
7063                                    resource: "CDC spilled row bytes",
7064                                    requested: payload.len(),
7065                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
7066                                });
7067                            }
7068                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
7069                            if decoded_rows
7070                                .saturating_add(rows.len())
7071                                .saturating_add(chunk.len())
7072                                > CDC_MAX_ROWS
7073                            {
7074                                return Err(MongrelError::ResourceLimitExceeded {
7075                                    resource: "CDC decoded rows",
7076                                    requested: decoded_rows
7077                                        .saturating_add(rows.len())
7078                                        .saturating_add(chunk.len()),
7079                                    limit: CDC_MAX_ROWS,
7080                                });
7081                            }
7082                            rows.extend(chunk);
7083                        }
7084                        rows
7085                    } else {
7086                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
7087                            return Ok(CdcBatch {
7088                                events: Vec::new(),
7089                                current_epoch,
7090                                earliest_epoch,
7091                                gap: true,
7092                            });
7093                        };
7094                        let table = handle.lock();
7095                        let mut reader = match table.open_reader(run.run_id) {
7096                            Ok(reader) => reader,
7097                            Err(_) => {
7098                                return Ok(CdcBatch {
7099                                    events: Vec::new(),
7100                                    current_epoch,
7101                                    earliest_epoch,
7102                                    gap: true,
7103                                })
7104                            }
7105                        };
7106                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
7107                        let rows = reader.all_rows_controlled(control, remaining)?;
7108                        drop(reader);
7109                        drop(table);
7110                        rows
7111                    };
7112                    for row in &mut rows {
7113                        row.committed_epoch = Epoch(*commit_epoch);
7114                    }
7115                    decoded_rows = decoded_rows.saturating_add(rows.len());
7116                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
7117                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
7118                    if events.len() >= CDC_MAX_EVENTS {
7119                        return Err(MongrelError::ResourceLimitExceeded {
7120                            resource: "CDC events",
7121                            requested: events.len().saturating_add(1),
7122                            limit: CDC_MAX_EVENTS,
7123                        });
7124                    }
7125                    events.push(ChangeEvent {
7126                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
7127                        channel: "changes".into(),
7128                        table_id: Some(run.table_id),
7129                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
7130                        op: "put_run".into(),
7131                        epoch: *commit_epoch,
7132                        txn_id: Some(record.txn_id),
7133                        message: None,
7134                        data: Some(serde_json::json!({
7135                            "run_id": run.run_id.to_string(),
7136                            "row_count": run.row_count,
7137                            "min_row_id": run.min_row_id,
7138                            "max_row_id": run.max_row_id,
7139                            "rows": rows,
7140                        })),
7141                    });
7142                }
7143            }
7144        }
7145        control.checkpoint()?;
7146        Ok(CdcBatch {
7147            events,
7148            current_epoch,
7149            earliest_epoch,
7150            gap: false,
7151        })
7152    }
7153
7154    /// Publish a notification message on a named channel. Reaches all active
7155    /// subscribers (daemon `/events`, application listeners).
7156    pub fn notify(&self, channel: &str, message: Option<String>) {
7157        let _ = self.notify.send(ChangeEvent {
7158            id: None,
7159            channel: channel.to_string(),
7160            table_id: None,
7161            table: String::new(),
7162            op: "notify".into(),
7163            epoch: self.epoch.visible().0,
7164            txn_id: None,
7165            message,
7166            data: None,
7167        });
7168    }
7169
7170    pub fn call_procedure(
7171        &self,
7172        name: &str,
7173        args: HashMap<String, crate::Value>,
7174    ) -> Result<ProcedureCallResult> {
7175        self.call_procedure_as(name, args, None)
7176    }
7177
7178    pub fn call_procedure_as(
7179        &self,
7180        name: &str,
7181        args: HashMap<String, crate::Value>,
7182        principal: Option<&crate::auth::Principal>,
7183    ) -> Result<ProcedureCallResult> {
7184        let control = crate::ExecutionControl::new(None);
7185        self.call_procedure_as_controlled(name, args, principal, &control, || true)
7186    }
7187
7188    /// Execute only the exact procedure revision previously authorized by the
7189    /// caller. A dropped or replaced definition fails closed.
7190    #[doc(hidden)]
7191    pub fn call_procedure_as_bound(
7192        &self,
7193        expected: &StoredProcedure,
7194        args: HashMap<String, crate::Value>,
7195        principal: Option<&crate::auth::Principal>,
7196    ) -> Result<ProcedureCallResult> {
7197        self.require_for(principal, &crate::auth::Permission::All)?;
7198        let procedure = self.procedure(&expected.name).ok_or_else(|| {
7199            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
7200        })?;
7201        if &procedure != expected {
7202            return Err(MongrelError::Conflict(format!(
7203                "procedure {:?} changed after request authorization",
7204                expected.name
7205            )));
7206        }
7207        let control = crate::ExecutionControl::new(None);
7208        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
7209    }
7210
7211    /// Execute a procedure with cooperative cancellation during preparation.
7212    /// `before_commit` runs after every procedure step has succeeded and
7213    /// immediately before a write procedure commits. Returning `false` aborts
7214    /// the transaction without publishing it.
7215    #[doc(hidden)]
7216    pub fn call_procedure_as_controlled<F>(
7217        &self,
7218        name: &str,
7219        args: HashMap<String, crate::Value>,
7220        principal: Option<&crate::auth::Principal>,
7221        control: &crate::ExecutionControl,
7222        before_commit: F,
7223    ) -> Result<ProcedureCallResult>
7224    where
7225        F: FnOnce() -> bool,
7226    {
7227        // v1 requires ALL to call procedures on a require_auth database; a
7228        // finer SECURITY DEFINER-style marker is a future extension (spec §9
7229        // decision 1).
7230        self.require_for(principal, &crate::auth::Permission::All)?;
7231        let procedure = self
7232            .procedure(name)
7233            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
7234        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
7235    }
7236
7237    fn execute_procedure_as_controlled<F>(
7238        &self,
7239        procedure: StoredProcedure,
7240        args: HashMap<String, crate::Value>,
7241        principal: Option<&crate::auth::Principal>,
7242        control: &crate::ExecutionControl,
7243        before_commit: F,
7244    ) -> Result<ProcedureCallResult>
7245    where
7246        F: FnOnce() -> bool,
7247    {
7248        let args = bind_procedure_args(&procedure, args)?;
7249        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
7250        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
7251        if has_writes {
7252            let mut tx = self.begin_as(principal.cloned());
7253            let run = (|| {
7254                for (step_index, step) in procedure.body.steps.iter().enumerate() {
7255                    if step_index % 256 == 0 {
7256                        control.checkpoint()?;
7257                    }
7258                    let output = self.execute_procedure_step(
7259                        step,
7260                        &args,
7261                        &outputs,
7262                        Some(&mut tx),
7263                        principal,
7264                        Some(control),
7265                    )?;
7266                    outputs.insert(step.id().to_string(), output);
7267                }
7268                control.checkpoint()?;
7269                eval_return_output(&procedure.body.return_value, &args, &outputs)
7270            })();
7271            match run {
7272                Ok(output) => {
7273                    control.checkpoint()?;
7274                    if !before_commit() {
7275                        tx.rollback();
7276                        return Err(MongrelError::Cancelled);
7277                    }
7278                    let epoch = tx.commit()?.0;
7279                    Ok(ProcedureCallResult {
7280                        epoch: Some(epoch),
7281                        output,
7282                    })
7283                }
7284                Err(e) => {
7285                    tx.rollback();
7286                    Err(e)
7287                }
7288            }
7289        } else {
7290            for (step_index, step) in procedure.body.steps.iter().enumerate() {
7291                if step_index % 256 == 0 {
7292                    control.checkpoint()?;
7293                }
7294                let output = self.execute_procedure_step(
7295                    step,
7296                    &args,
7297                    &outputs,
7298                    None,
7299                    principal,
7300                    Some(control),
7301                )?;
7302                outputs.insert(step.id().to_string(), output);
7303            }
7304            control.checkpoint()?;
7305            Ok(ProcedureCallResult {
7306                epoch: None,
7307                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
7308            })
7309        }
7310    }
7311
7312    fn execute_procedure_step(
7313        &self,
7314        step: &ProcedureStep,
7315        args: &HashMap<String, crate::Value>,
7316        outputs: &HashMap<String, ProcedureCallOutput>,
7317        tx: Option<&mut crate::txn::Transaction<'_>>,
7318        principal: Option<&crate::auth::Principal>,
7319        control: Option<&crate::ExecutionControl>,
7320    ) -> Result<ProcedureCallOutput> {
7321        if let Some(control) = control {
7322            control.checkpoint()?;
7323        }
7324        match step {
7325            ProcedureStep::NativeQuery {
7326                table,
7327                conditions,
7328                projection,
7329                limit,
7330                ..
7331            } => {
7332                let mut q = crate::Query::new();
7333                for condition in conditions {
7334                    q = q.and(eval_condition(condition, args, outputs)?);
7335                }
7336                let fallback_control = crate::ExecutionControl::new(None);
7337                let query_control = control.unwrap_or(&fallback_control);
7338                let mut rows = self.query_for_principal_controlled(
7339                    table,
7340                    &q,
7341                    projection.as_deref(),
7342                    principal,
7343                    false,
7344                    query_control,
7345                )?;
7346                if let Some(limit) = limit {
7347                    rows.truncate(*limit);
7348                }
7349                let mut output = Vec::with_capacity(rows.len());
7350                for (row_index, row) in rows.into_iter().enumerate() {
7351                    if row_index % 256 == 0 {
7352                        if let Some(control) = control {
7353                            control.checkpoint()?;
7354                        }
7355                    }
7356                    output.push(ProcedureCallRow {
7357                        row_id: Some(row.row_id),
7358                        columns: row.columns,
7359                    });
7360                }
7361                Ok(ProcedureCallOutput::Rows(output))
7362            }
7363            ProcedureStep::Put {
7364                table,
7365                cells,
7366                returning,
7367                ..
7368            } => {
7369                let tx = tx.ok_or_else(|| {
7370                    MongrelError::InvalidArgument(
7371                        "write procedure step requires a transaction".into(),
7372                    )
7373                })?;
7374                let cells = eval_cells(cells, args, outputs)?;
7375                if *returning {
7376                    let out = tx.put_returning(table, cells)?;
7377                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7378                        row_id: None,
7379                        columns: out.row.columns.into_iter().collect(),
7380                    }))
7381                } else {
7382                    tx.put(table, cells)?;
7383                    Ok(ProcedureCallOutput::Null)
7384                }
7385            }
7386            ProcedureStep::Upsert {
7387                table,
7388                cells,
7389                update_cells,
7390                returning,
7391                ..
7392            } => {
7393                let tx = tx.ok_or_else(|| {
7394                    MongrelError::InvalidArgument(
7395                        "write procedure step requires a transaction".into(),
7396                    )
7397                })?;
7398                let cells = eval_cells(cells, args, outputs)?;
7399                let action = match update_cells {
7400                    Some(update_cells) => {
7401                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
7402                    }
7403                    None => crate::UpsertAction::DoNothing,
7404                };
7405                let out = tx.upsert(table, cells, action)?;
7406                if *returning {
7407                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7408                        row_id: None,
7409                        columns: out.row.columns.into_iter().collect(),
7410                    }))
7411                } else {
7412                    Ok(ProcedureCallOutput::Null)
7413                }
7414            }
7415            ProcedureStep::DeleteByPk { table, pk, .. } => {
7416                let tx = tx.ok_or_else(|| {
7417                    MongrelError::InvalidArgument(
7418                        "write procedure step requires a transaction".into(),
7419                    )
7420                })?;
7421                let pk = eval_value(pk, args, outputs)?;
7422                let handle = self.table(table)?;
7423                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
7424                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
7425                })?;
7426                tx.delete(table, row_id)?;
7427                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
7428            }
7429            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
7430                "DeleteRows procedure step is not supported by the core executor yet".into(),
7431            )),
7432            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
7433                "SqlQuery procedure step must be executed by mongreldb-query".into(),
7434            )),
7435        }
7436    }
7437
7438    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
7439        let cat = self.catalog.read();
7440        for step in &procedure.body.steps {
7441            let Some(table_name) = step.table() else {
7442                continue;
7443            };
7444            let schema = &cat
7445                .live(table_name)
7446                .ok_or_else(|| {
7447                    MongrelError::InvalidArgument(format!(
7448                        "procedure {:?} references unknown table {table_name:?}",
7449                        procedure.name
7450                    ))
7451                })?
7452                .schema;
7453            match step {
7454                ProcedureStep::NativeQuery {
7455                    conditions,
7456                    projection,
7457                    ..
7458                } => {
7459                    for condition in conditions {
7460                        validate_condition_columns(condition, schema)?;
7461                    }
7462                    if let Some(projection) = projection {
7463                        for id in projection {
7464                            validate_column_id(*id, schema)?;
7465                        }
7466                    }
7467                }
7468                ProcedureStep::Put { cells, .. } => {
7469                    for cell in cells {
7470                        validate_column_id(cell.column_id, schema)?;
7471                    }
7472                }
7473                ProcedureStep::Upsert {
7474                    cells,
7475                    update_cells,
7476                    ..
7477                } => {
7478                    for cell in cells {
7479                        validate_column_id(cell.column_id, schema)?;
7480                    }
7481                    if let Some(update_cells) = update_cells {
7482                        for cell in update_cells {
7483                            validate_column_id(cell.column_id, schema)?;
7484                        }
7485                    }
7486                }
7487                ProcedureStep::DeleteByPk { .. } => {
7488                    if schema.primary_key().is_none() {
7489                        return Err(MongrelError::InvalidArgument(format!(
7490                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
7491                            procedure.name
7492                        )));
7493                    }
7494                }
7495                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
7496            }
7497        }
7498        Ok(())
7499    }
7500
7501    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
7502        let cat = self.catalog.read();
7503        let target_schema = match &trigger.target {
7504            TriggerTarget::Table(target_name) => cat
7505                .live(target_name)
7506                .ok_or_else(|| {
7507                    MongrelError::InvalidArgument(format!(
7508                        "trigger {:?} references unknown target table {target_name:?}",
7509                        trigger.name
7510                    ))
7511                })?
7512                .schema
7513                .clone(),
7514            TriggerTarget::View(_) => Schema {
7515                columns: trigger.target_columns.clone(),
7516                ..Schema::default()
7517            },
7518        };
7519        for col in &trigger.update_of {
7520            if target_schema.column(col).is_none() {
7521                return Err(MongrelError::InvalidArgument(format!(
7522                    "trigger {:?} UPDATE OF references unknown column {col:?}",
7523                    trigger.name
7524                )));
7525            }
7526        }
7527        if let Some(expr) = &trigger.when {
7528            validate_trigger_expr(expr, &target_schema, trigger.event)?;
7529        }
7530        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
7531        for step in &trigger.program.steps {
7532            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
7533            {
7534                return Err(MongrelError::InvalidArgument(
7535                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
7536                ));
7537            }
7538            validate_trigger_step(
7539                step,
7540                &cat,
7541                &target_schema,
7542                trigger.event,
7543                &mut select_schemas,
7544            )?;
7545        }
7546        Ok(())
7547    }
7548
7549    /// Begin a new transaction reading at the current visible epoch.
7550    pub fn begin(&self) -> crate::txn::Transaction<'_> {
7551        self.begin_with_isolation(crate::txn::IsolationLevel::default())
7552    }
7553
7554    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
7555        let principal = self.principal.read().clone();
7556        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7557            let catalog = self.catalog.read();
7558            catalog.require_auth || principal.user_id != 0
7559        });
7560        (principal, catalog_bound)
7561    }
7562
7563    pub fn begin_as(
7564        &self,
7565        principal: Option<crate::auth::Principal>,
7566    ) -> crate::txn::Transaction<'_> {
7567        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7568            let catalog = self.catalog.read();
7569            catalog.require_auth || principal.user_id != 0
7570        });
7571        let txn_id = self.alloc_txn_id();
7572        let read = Snapshot::at(self.epoch.visible());
7573        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7574    }
7575
7576    /// Begin a transaction with a specific isolation level.
7577    pub fn begin_with_isolation(
7578        &self,
7579        level: crate::txn::IsolationLevel,
7580    ) -> crate::txn::Transaction<'_> {
7581        let txn_id = self.alloc_txn_id();
7582        let epoch = match level {
7583            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
7584            _ => self.epoch.visible(),
7585        };
7586        let read = Snapshot::at(epoch);
7587        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7588        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7589    }
7590
7591    /// Begin a transaction whose trigger programs may route external-table DML
7592    /// through an application/query-layer module bridge.
7593    pub fn begin_with_external_trigger_bridge<'a>(
7594        &'a self,
7595        bridge: &'a dyn ExternalTriggerBridge,
7596    ) -> crate::txn::Transaction<'a> {
7597        let txn_id = self.alloc_txn_id();
7598        let read = Snapshot::at(self.epoch.visible());
7599        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7600        crate::txn::Transaction::new(self, txn_id, read)
7601            .with_external_trigger_bridge(bridge)
7602            .with_principal(principal, catalog_bound)
7603    }
7604
7605    pub fn begin_with_external_trigger_bridge_as<'a>(
7606        &'a self,
7607        bridge: &'a dyn ExternalTriggerBridge,
7608        principal: Option<crate::auth::Principal>,
7609    ) -> crate::txn::Transaction<'a> {
7610        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7611            let catalog = self.catalog.read();
7612            catalog.require_auth || principal.user_id != 0
7613        });
7614        let txn_id = self.alloc_txn_id();
7615        let read = Snapshot::at(self.epoch.visible());
7616        crate::txn::Transaction::new(self, txn_id, read)
7617            .with_external_trigger_bridge(bridge)
7618            .with_principal(principal, catalog_bound)
7619    }
7620
7621    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
7622    pub fn transaction<T>(
7623        &self,
7624        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7625    ) -> Result<T> {
7626        let mut tx = self.begin();
7627        match f(&mut tx) {
7628            Ok(out) => {
7629                tx.commit()?;
7630                Ok(out)
7631            }
7632            Err(e) => {
7633                tx.rollback();
7634                Err(e)
7635            }
7636        }
7637    }
7638
7639    pub fn transaction_with_row_ids<T>(
7640        &self,
7641        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7642    ) -> Result<(T, Vec<RowId>)> {
7643        let mut tx = self.begin();
7644        match f(&mut tx) {
7645            Ok(output) => {
7646                let (_, row_ids) = tx.commit_with_row_ids()?;
7647                Ok((output, row_ids))
7648            }
7649            Err(error) => {
7650                tx.rollback();
7651                Err(error)
7652            }
7653        }
7654    }
7655
7656    pub fn transaction_for_current_principal<T>(
7657        &self,
7658        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7659    ) -> Result<T> {
7660        if self.principal.read().is_some() {
7661            self.refresh_principal()?;
7662        }
7663        let mut transaction = self.begin_as(self.principal.read().clone());
7664        match f(&mut transaction) {
7665            Ok(output) => {
7666                transaction.commit()?;
7667                Ok(output)
7668            }
7669            Err(error) => {
7670                transaction.rollback();
7671                Err(error)
7672            }
7673        }
7674    }
7675
7676    pub fn transaction_for_current_principal_with_epoch<T>(
7677        &self,
7678        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7679    ) -> Result<(Epoch, T)> {
7680        if self.principal.read().is_some() {
7681            self.refresh_principal()?;
7682        }
7683        let mut transaction = self.begin_as(self.principal.read().clone());
7684        match f(&mut transaction) {
7685            Ok(output) => {
7686                let epoch = transaction.commit()?;
7687                Ok((epoch, output))
7688            }
7689            Err(error) => {
7690                transaction.rollback();
7691                Err(error)
7692            }
7693        }
7694    }
7695
7696    pub fn transaction_with_row_ids_for_current_principal<T>(
7697        &self,
7698        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7699    ) -> Result<(T, Vec<RowId>)> {
7700        if self.principal.read().is_some() {
7701            self.refresh_principal()?;
7702        }
7703        let mut transaction = self.begin_as(self.principal.read().clone());
7704        match f(&mut transaction) {
7705            Ok(output) => {
7706                let (_, row_ids) = transaction.commit_with_row_ids()?;
7707                Ok((output, row_ids))
7708            }
7709            Err(error) => {
7710                transaction.rollback();
7711                Err(error)
7712            }
7713        }
7714    }
7715
7716    /// Run `f` in a transaction with an external-trigger bridge; commit on
7717    /// `Ok`, rollback on `Err`.
7718    pub fn transaction_with_external_trigger_bridge<'a, T>(
7719        &'a self,
7720        bridge: &'a dyn ExternalTriggerBridge,
7721        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7722    ) -> Result<T> {
7723        let mut tx = self.begin_with_external_trigger_bridge(bridge);
7724        match f(&mut tx) {
7725            Ok(out) => {
7726                tx.commit()?;
7727                Ok(out)
7728            }
7729            Err(e) => {
7730                tx.rollback();
7731                Err(e)
7732            }
7733        }
7734    }
7735
7736    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
7737        &'a self,
7738        bridge: &'a dyn ExternalTriggerBridge,
7739        principal: Option<crate::auth::Principal>,
7740        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7741    ) -> Result<T> {
7742        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
7743        match f(&mut tx) {
7744            Ok(output) => {
7745                tx.commit()?;
7746                Ok(output)
7747            }
7748            Err(error) => {
7749                tx.rollback();
7750                Err(error)
7751            }
7752        }
7753    }
7754
7755    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
7756    /// `Transaction::new` so registration happens **before** any read.
7757    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
7758        self.active_txns.register(epoch)
7759    }
7760
7761    fn fill_auto_increment_for_staging(
7762        &self,
7763        staging: &mut [(u64, crate::txn::Staged)],
7764        control: Option<&crate::ExecutionControl>,
7765    ) -> Result<()> {
7766        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
7767        for (index, (table_id, staged)) in staging.iter().enumerate() {
7768            commit_prepare_checkpoint(control, index)?;
7769            if matches!(staged, crate::txn::Staged::Put(_)) {
7770                puts_by_table.entry(*table_id).or_default().push(index);
7771            }
7772        }
7773
7774        let tables = self.tables.read();
7775        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
7776            commit_prepare_checkpoint(control, table_index)?;
7777            if let Some(handle) = tables.get(&table_id) {
7778                #[cfg(test)]
7779                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
7780                let mut t = handle.lock();
7781                for (fill_index, index) in indexes.into_iter().enumerate() {
7782                    commit_prepare_checkpoint(control, fill_index)?;
7783                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
7784                        t.fill_auto_inc(cells)?;
7785                    }
7786                }
7787            }
7788        }
7789        Ok(())
7790    }
7791
7792    fn expand_table_triggers(
7793        &self,
7794        staging: &mut Vec<(u64, crate::txn::Staged)>,
7795        read_epoch: Epoch,
7796        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
7797        external_states: &mut Vec<(String, Vec<u8>)>,
7798        control: Option<&crate::ExecutionControl>,
7799    ) -> Result<()> {
7800        commit_prepare_checkpoint(control, 0)?;
7801        let mut external_writes = Vec::new();
7802        let config = self.trigger_config();
7803        if config.recursive_triggers {
7804            let chunk = std::mem::take(staging);
7805            let stacks = vec![Vec::new(); chunk.len()];
7806            *staging = self.expand_trigger_chunk(
7807                chunk,
7808                stacks,
7809                read_epoch,
7810                0,
7811                config.max_depth,
7812                &mut external_writes,
7813                &config,
7814                control,
7815            )?;
7816            self.apply_external_trigger_writes(
7817                external_writes,
7818                external_trigger_bridge,
7819                external_states,
7820                staging,
7821                control,
7822            )?;
7823            return Ok(());
7824        }
7825
7826        let mut expansion =
7827            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
7828        if !expansion.before.is_empty() {
7829            let mut final_staging = expansion.before;
7830            final_staging.extend(filter_ignored_staging(
7831                std::mem::take(staging),
7832                &expansion.ignored_indices,
7833            ));
7834            *staging = final_staging;
7835        } else if !expansion.ignored_indices.is_empty() {
7836            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
7837        }
7838        staging.append(&mut expansion.after);
7839        external_writes.append(&mut expansion.before_external);
7840        external_writes.append(&mut expansion.after_external);
7841        self.apply_external_trigger_writes(
7842            external_writes,
7843            external_trigger_bridge,
7844            external_states,
7845            staging,
7846            control,
7847        )?;
7848        Ok(())
7849    }
7850
7851    #[allow(clippy::too_many_arguments)]
7852    fn expand_trigger_chunk(
7853        &self,
7854        mut chunk: Vec<(u64, crate::txn::Staged)>,
7855        stacks: Vec<Vec<String>>,
7856        read_epoch: Epoch,
7857        depth: u32,
7858        max_depth: u32,
7859        external_writes: &mut Vec<ExternalTriggerWrite>,
7860        config: &TriggerConfig,
7861        control: Option<&crate::ExecutionControl>,
7862    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
7863        if chunk.is_empty() {
7864            return Ok(Vec::new());
7865        }
7866        commit_prepare_checkpoint(control, 0)?;
7867        self.fill_auto_increment_for_staging(&mut chunk, control)?;
7868        let expansion = self.expand_table_triggers_once(
7869            &mut chunk,
7870            read_epoch,
7871            Some(&stacks),
7872            config,
7873            control,
7874        )?;
7875        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
7876            let stack = expansion
7877                .before_stacks
7878                .first()
7879                .or_else(|| expansion.after_stacks.first())
7880                .cloned()
7881                .unwrap_or_default();
7882            return Err(MongrelError::TriggerValidation(format!(
7883                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
7884                Self::format_trigger_stack(&stack)
7885            )));
7886        }
7887
7888        let mut out = Vec::new();
7889        external_writes.extend(expansion.before_external);
7890        out.extend(self.expand_trigger_chunk(
7891            expansion.before,
7892            expansion.before_stacks,
7893            read_epoch,
7894            depth + 1,
7895            max_depth,
7896            external_writes,
7897            config,
7898            control,
7899        )?);
7900        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
7901        external_writes.extend(expansion.after_external);
7902        out.extend(self.expand_trigger_chunk(
7903            expansion.after,
7904            expansion.after_stacks,
7905            read_epoch,
7906            depth + 1,
7907            max_depth,
7908            external_writes,
7909            config,
7910            control,
7911        )?);
7912        Ok(out)
7913    }
7914
7915    fn apply_external_trigger_writes(
7916        &self,
7917        writes: Vec<ExternalTriggerWrite>,
7918        bridge: Option<&dyn ExternalTriggerBridge>,
7919        external_states: &mut Vec<(String, Vec<u8>)>,
7920        staging: &mut Vec<(u64, crate::txn::Staged)>,
7921        control: Option<&crate::ExecutionControl>,
7922    ) -> Result<()> {
7923        if writes.is_empty() {
7924            return Ok(());
7925        }
7926        let bridge = bridge.ok_or_else(|| {
7927            MongrelError::TriggerValidation(
7928                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
7929            )
7930        })?;
7931        for (write_index, write) in writes.into_iter().enumerate() {
7932            commit_prepare_checkpoint(control, write_index)?;
7933            let table = write.table().to_string();
7934            let entry = self.external_table(&table).ok_or_else(|| {
7935                MongrelError::NotFound(format!("external table {table:?} not found"))
7936            })?;
7937            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
7938            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
7939            external_states.push((table, result.state));
7940            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
7941                commit_prepare_checkpoint(control, base_index)?;
7942                match base_write {
7943                    ExternalTriggerBaseWrite::Put { table, cells } => {
7944                        let table_id = self.table_id(&table)?;
7945                        staging.push((table_id, crate::txn::Staged::Put(cells)));
7946                    }
7947                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
7948                        let table_id = self.table_id(&table)?;
7949                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
7950                    }
7951                }
7952            }
7953        }
7954        dedup_external_states_in_place(external_states);
7955        Ok(())
7956    }
7957
7958    fn expand_table_triggers_once(
7959        &self,
7960        staging: &mut Vec<(u64, crate::txn::Staged)>,
7961        read_epoch: Epoch,
7962        trigger_stacks: Option<&[Vec<String>]>,
7963        config: &TriggerConfig,
7964        control: Option<&crate::ExecutionControl>,
7965    ) -> Result<TriggerExpansion> {
7966        commit_prepare_checkpoint(control, 0)?;
7967        let triggers: Vec<StoredTrigger> = self
7968            .catalog
7969            .read()
7970            .triggers
7971            .iter()
7972            .filter(|entry| {
7973                entry.trigger.enabled
7974                    && matches!(
7975                        entry.trigger.timing,
7976                        TriggerTiming::Before | TriggerTiming::After
7977                    )
7978                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
7979            })
7980            .map(|entry| entry.trigger.clone())
7981            .collect();
7982        if triggers.is_empty() || staging.is_empty() {
7983            return Ok(TriggerExpansion::default());
7984        }
7985
7986        let before_triggers = triggers
7987            .iter()
7988            .filter(|trigger| trigger.timing == TriggerTiming::Before)
7989            .cloned()
7990            .collect::<Vec<_>>();
7991        let after_triggers = triggers
7992            .iter()
7993            .filter(|trigger| trigger.timing == TriggerTiming::After)
7994            .cloned()
7995            .collect::<Vec<_>>();
7996
7997        let mut before_added = Vec::new();
7998        let mut before_stacks = Vec::new();
7999        let mut before_external = Vec::new();
8000        let mut ignored_indices = std::collections::BTreeSet::new();
8001        if !before_triggers.is_empty() {
8002            let before_events =
8003                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
8004            let mut out = TriggerProgramOutput {
8005                added: &mut before_added,
8006                added_stacks: &mut before_stacks,
8007                added_external: &mut before_external,
8008                ignored_indices: &mut ignored_indices,
8009            };
8010            self.execute_triggers_for_events(
8011                &before_triggers,
8012                &before_events,
8013                Some(staging),
8014                &mut out,
8015                config,
8016                read_epoch,
8017                control,
8018            )?;
8019        }
8020
8021        let after_events = if after_triggers.is_empty() {
8022            Vec::new()
8023        } else {
8024            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
8025                .into_iter()
8026                .filter(|event| {
8027                    !event
8028                        .op_indices
8029                        .iter()
8030                        .any(|idx| ignored_indices.contains(idx))
8031                })
8032                .collect()
8033        };
8034
8035        let mut after_added = Vec::new();
8036        let mut after_stacks = Vec::new();
8037        let mut after_external = Vec::new();
8038        let mut out = TriggerProgramOutput {
8039            added: &mut after_added,
8040            added_stacks: &mut after_stacks,
8041            added_external: &mut after_external,
8042            ignored_indices: &mut ignored_indices,
8043        };
8044        self.execute_triggers_for_events(
8045            &after_triggers,
8046            &after_events,
8047            None,
8048            &mut out,
8049            config,
8050            read_epoch,
8051            control,
8052        )?;
8053        Ok(TriggerExpansion {
8054            before: before_added,
8055            before_stacks,
8056            before_external,
8057            after: after_added,
8058            after_stacks,
8059            after_external,
8060            ignored_indices,
8061        })
8062    }
8063
8064    #[allow(clippy::too_many_arguments)]
8065    fn execute_triggers_for_events(
8066        &self,
8067        triggers: &[StoredTrigger],
8068        events: &[WriteEvent],
8069        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8070        out: &mut TriggerProgramOutput<'_>,
8071        config: &TriggerConfig,
8072        read_epoch: Epoch,
8073        control: Option<&crate::ExecutionControl>,
8074    ) -> Result<()> {
8075        let mut checkpoint_index = 0_usize;
8076        for event in events {
8077            for trigger in triggers {
8078                commit_prepare_checkpoint(control, checkpoint_index)?;
8079                checkpoint_index += 1;
8080                if event
8081                    .op_indices
8082                    .iter()
8083                    .any(|idx| out.ignored_indices.contains(idx))
8084                {
8085                    break;
8086                }
8087                let matches = {
8088                    let cat = self.catalog.read();
8089                    trigger_matches_event(trigger, event, &cat)?
8090                };
8091                if !matches {
8092                    continue;
8093                }
8094                if let Some(when) = &trigger.when {
8095                    if !eval_trigger_expr(when, event)? {
8096                        continue;
8097                    }
8098                }
8099                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
8100                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
8101                    return Err(MongrelError::TriggerValidation(format!(
8102                        "trigger recursion cycle detected; trigger stack: {}",
8103                        Self::format_trigger_stack(&trigger_stack)
8104                    )));
8105                }
8106                let outcome = match staging.as_mut() {
8107                    Some(staging) => self.execute_trigger_program(
8108                        trigger,
8109                        event,
8110                        Some(&mut **staging),
8111                        out,
8112                        &trigger_stack,
8113                        config,
8114                        read_epoch,
8115                        control,
8116                    )?,
8117                    None => self.execute_trigger_program(
8118                        trigger,
8119                        event,
8120                        None,
8121                        out,
8122                        &trigger_stack,
8123                        config,
8124                        read_epoch,
8125                        control,
8126                    )?,
8127                };
8128                if outcome == TriggerProgramOutcome::Ignore {
8129                    out.ignored_indices.extend(event.op_indices.iter().copied());
8130                    break;
8131                }
8132            }
8133        }
8134        Ok(())
8135    }
8136
8137    fn trigger_events_for_staging(
8138        &self,
8139        staging: &[(u64, crate::txn::Staged)],
8140        read_epoch: Epoch,
8141        trigger_stacks: Option<&[Vec<String>]>,
8142        control: Option<&crate::ExecutionControl>,
8143    ) -> Result<Vec<WriteEvent>> {
8144        use crate::txn::Staged;
8145        use std::collections::{HashMap, VecDeque};
8146
8147        let snapshot = Snapshot::at(read_epoch);
8148        let cat = self.catalog.read();
8149        let mut table_names = HashMap::new();
8150        let mut table_schemas = HashMap::new();
8151        for entry in cat
8152            .tables
8153            .iter()
8154            .filter(|entry| matches!(entry.state, TableState::Live))
8155        {
8156            table_names.insert(entry.table_id, entry.name.clone());
8157            table_schemas.insert(entry.table_id, entry.schema.clone());
8158        }
8159        drop(cat);
8160
8161        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
8162        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
8163        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
8164
8165        for (idx, (table_id, staged)) in staging.iter().enumerate() {
8166            commit_prepare_checkpoint(control, idx)?;
8167            let Some(schema) = table_schemas.get(table_id) else {
8168                continue;
8169            };
8170            let Some(pk) = schema.primary_key() else {
8171                continue;
8172            };
8173            match staged {
8174                Staged::Delete(row_id) => {
8175                    let handle = self.table_by_id(*table_id)?;
8176                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
8177                        continue;
8178                    };
8179                    let Some(pk_value) = row.columns.get(&pk.id) else {
8180                        continue;
8181                    };
8182                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
8183                    delete_by_key
8184                        .entry((*table_id, pk_value.encode_key()))
8185                        .or_default()
8186                        .push_back(idx);
8187                }
8188                Staged::Put(cells) => {
8189                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
8190                        put_by_key
8191                            .entry((*table_id, value.encode_key()))
8192                            .or_default()
8193                            .push_back(idx);
8194                    }
8195                }
8196                Staged::Update { row_id, .. } => {
8197                    let handle = self.table_by_id(*table_id)?;
8198                    let row = handle.lock().get(*row_id, snapshot);
8199                    if let Some(row) = row {
8200                        old_rows.insert(idx, TriggerRowImage::from_row(row));
8201                    }
8202                }
8203                Staged::Truncate => {}
8204            }
8205        }
8206
8207        let mut paired_delete = std::collections::HashSet::new();
8208        let mut paired_put = std::collections::HashSet::new();
8209        let mut events = Vec::new();
8210
8211        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
8212            commit_prepare_checkpoint(control, pair_index)?;
8213            let Some(puts) = put_by_key.get_mut(key) else {
8214                continue;
8215            };
8216            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
8217                paired_delete.insert(delete_idx);
8218                paired_put.insert(put_idx);
8219                let (table_id, _) = &staging[put_idx];
8220                let Some(table_name) = table_names.get(table_id).cloned() else {
8221                    continue;
8222                };
8223                let old = old_rows.get(&delete_idx).cloned();
8224                let new = match &staging[put_idx].1 {
8225                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
8226                    _ => None,
8227                };
8228                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
8229                events.push(WriteEvent {
8230                    table: table_name,
8231                    kind: TriggerEvent::Update,
8232                    old,
8233                    new,
8234                    changed_columns,
8235                    op_indices: vec![delete_idx, put_idx],
8236                    put_idx: Some(put_idx),
8237                    trigger_stack: Self::trigger_stack_for_indices(
8238                        trigger_stacks,
8239                        &[delete_idx, put_idx],
8240                    ),
8241                });
8242            }
8243        }
8244
8245        for (idx, (table_id, staged)) in staging.iter().enumerate() {
8246            commit_prepare_checkpoint(control, idx)?;
8247            let Some(table_name) = table_names.get(table_id).cloned() else {
8248                continue;
8249            };
8250            match staged {
8251                Staged::Put(cells) if !paired_put.contains(&idx) => {
8252                    let new = Some(TriggerRowImage::from_cells(cells));
8253                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
8254                    events.push(WriteEvent {
8255                        table: table_name,
8256                        kind: TriggerEvent::Insert,
8257                        old: None,
8258                        new,
8259                        changed_columns,
8260                        op_indices: vec![idx],
8261                        put_idx: Some(idx),
8262                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8263                    });
8264                }
8265                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
8266                    let old = match old_rows.get(&idx).cloned() {
8267                        Some(old) => Some(old),
8268                        None => {
8269                            let handle = self.table_by_id(*table_id)?;
8270                            let row = handle.lock().get(*row_id, snapshot);
8271                            row.map(TriggerRowImage::from_row)
8272                        }
8273                    };
8274                    let Some(old) = old else {
8275                        continue;
8276                    };
8277                    let changed_columns = old.columns.keys().copied().collect();
8278                    events.push(WriteEvent {
8279                        table: table_name,
8280                        kind: TriggerEvent::Delete,
8281                        old: Some(old),
8282                        new: None,
8283                        changed_columns,
8284                        op_indices: vec![idx],
8285                        put_idx: None,
8286                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8287                    });
8288                }
8289                Staged::Update { new_row: cells, .. } => {
8290                    let old = old_rows.get(&idx).cloned();
8291                    let new = Some(TriggerRowImage::from_cells(cells));
8292                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
8293                    events.push(WriteEvent {
8294                        table: table_name,
8295                        kind: TriggerEvent::Update,
8296                        old,
8297                        new,
8298                        changed_columns,
8299                        op_indices: vec![idx],
8300                        put_idx: Some(idx),
8301                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8302                    });
8303                }
8304                Staged::Truncate => {}
8305                _ => {}
8306            }
8307        }
8308
8309        Ok(events)
8310    }
8311
8312    #[allow(clippy::too_many_arguments)]
8313    fn execute_trigger_program(
8314        &self,
8315        trigger: &StoredTrigger,
8316        event: &WriteEvent,
8317        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8318        out: &mut TriggerProgramOutput<'_>,
8319        trigger_stack: &[String],
8320        config: &TriggerConfig,
8321        read_epoch: Epoch,
8322        control: Option<&crate::ExecutionControl>,
8323    ) -> Result<TriggerProgramOutcome> {
8324        let mut event = event.clone();
8325        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
8326        self.execute_trigger_steps(
8327            trigger,
8328            &trigger.program.steps,
8329            &mut event,
8330            staging,
8331            out,
8332            trigger_stack,
8333            config,
8334            &mut select_results,
8335            0,
8336            None,
8337            read_epoch,
8338            control,
8339        )
8340    }
8341
8342    #[allow(clippy::too_many_arguments)]
8343    fn execute_trigger_steps(
8344        &self,
8345        trigger: &StoredTrigger,
8346        steps: &[TriggerStep],
8347        event: &mut WriteEvent,
8348        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8349        out: &mut TriggerProgramOutput<'_>,
8350        trigger_stack: &[String],
8351        config: &TriggerConfig,
8352        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
8353        depth: u32,
8354        selected: Option<&TriggerRowImage>,
8355        read_epoch: Epoch,
8356        control: Option<&crate::ExecutionControl>,
8357    ) -> Result<TriggerProgramOutcome> {
8358        let _ = depth;
8359        for (step_index, step) in steps.iter().enumerate() {
8360            commit_prepare_checkpoint(control, step_index)?;
8361            match step {
8362                TriggerStep::SetNew { cells } => {
8363                    if trigger.timing != TriggerTiming::Before {
8364                        return Err(MongrelError::InvalidArgument(
8365                            "SetNew trigger step is only valid in BEFORE triggers".into(),
8366                        ));
8367                    }
8368                    let put_idx = event.put_idx.ok_or_else(|| {
8369                        MongrelError::InvalidArgument(
8370                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
8371                        )
8372                    })?;
8373                    let staging = staging.as_deref_mut().ok_or_else(|| {
8374                        MongrelError::InvalidArgument(
8375                            "SetNew trigger step requires mutable trigger staging".into(),
8376                        )
8377                    })?;
8378                    let mut update_changed_columns = None;
8379                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
8380                        Some(crate::txn::Staged::Put(cells)) => cells,
8381                        Some(crate::txn::Staged::Update {
8382                            new_row,
8383                            changed_columns,
8384                            ..
8385                        }) => {
8386                            update_changed_columns = Some(changed_columns);
8387                            new_row
8388                        }
8389                        _ => {
8390                            return Err(MongrelError::InvalidArgument(
8391                                "SetNew trigger step target row is not mutable".into(),
8392                            ))
8393                        }
8394                    };
8395                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
8396                        row_cells.retain(|(id, _)| *id != column_id);
8397                        row_cells.push((column_id, value.clone()));
8398                        if let Some(changed_columns) = &mut update_changed_columns {
8399                            changed_columns.push(column_id);
8400                        }
8401                        if let Some(new) = &mut event.new {
8402                            new.columns.insert(column_id, value);
8403                        }
8404                    }
8405                    row_cells.sort_by_key(|(id, _)| *id);
8406                    if let Some(changed_columns) = update_changed_columns {
8407                        changed_columns.sort_unstable();
8408                        changed_columns.dedup();
8409                    }
8410                }
8411                TriggerStep::Insert { table, cells } => {
8412                    let cells = eval_trigger_cells(cells, event, selected)?;
8413                    if let Ok(table_id) = self.table_id(table) {
8414                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
8415                        out.added_stacks.push(trigger_stack.to_vec());
8416                    } else if self.external_table(table).is_some() {
8417                        out.added_external.push(ExternalTriggerWrite::Insert {
8418                            table: table.clone(),
8419                            cells,
8420                        });
8421                    } else {
8422                        return Err(MongrelError::NotFound(format!(
8423                            "trigger {:?} insert target {table:?} not found",
8424                            trigger.name
8425                        )));
8426                    }
8427                }
8428                TriggerStep::UpdateByPk { table, pk, cells } => {
8429                    let pk = eval_trigger_value(pk, event, selected)?;
8430                    let cells = eval_trigger_cells(cells, event, selected)?;
8431                    if self.external_table(table).is_some() {
8432                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
8433                            table: table.clone(),
8434                            pk,
8435                            cells,
8436                        });
8437                    } else {
8438                        let row_id = self
8439                            .table(table)?
8440                            .lock()
8441                            .lookup_pk(&pk.encode_key())
8442                            .ok_or_else(|| {
8443                                MongrelError::NotFound(format!(
8444                                    "trigger {:?} update target not found",
8445                                    trigger.name
8446                                ))
8447                            })?;
8448                        let handle = self.table(table)?;
8449                        let snapshot = Snapshot::at(self.epoch.visible());
8450                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
8451                            MongrelError::NotFound(format!(
8452                                "trigger {:?} update target not visible",
8453                                trigger.name
8454                            ))
8455                        })?;
8456                        let mut changed_columns = cells
8457                            .iter()
8458                            .map(|(column_id, _)| *column_id)
8459                            .collect::<Vec<_>>();
8460                        changed_columns.sort_unstable();
8461                        changed_columns.dedup();
8462                        let mut merged = old.columns;
8463                        for (column_id, value) in cells {
8464                            merged.insert(column_id, value);
8465                        }
8466                        out.added.push((
8467                            self.table_id(table)?,
8468                            crate::txn::Staged::Update {
8469                                row_id,
8470                                new_row: merged.into_iter().collect(),
8471                                changed_columns,
8472                            },
8473                        ));
8474                        out.added_stacks.push(trigger_stack.to_vec());
8475                    }
8476                }
8477                TriggerStep::DeleteByPk { table, pk } => {
8478                    let pk = eval_trigger_value(pk, event, selected)?;
8479                    if self.external_table(table).is_some() {
8480                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
8481                            table: table.clone(),
8482                            pk,
8483                        });
8484                    } else {
8485                        let row_id = self
8486                            .table(table)?
8487                            .lock()
8488                            .lookup_pk(&pk.encode_key())
8489                            .ok_or_else(|| {
8490                                MongrelError::NotFound(format!(
8491                                    "trigger {:?} delete target not found",
8492                                    trigger.name
8493                                ))
8494                            })?;
8495                        out.added
8496                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
8497                        out.added_stacks.push(trigger_stack.to_vec());
8498                    }
8499                }
8500                TriggerStep::Select {
8501                    id,
8502                    table,
8503                    conditions,
8504                } => {
8505                    let schema = self.table(table)?.lock().schema().clone();
8506                    let snapshot = Snapshot::at(read_epoch);
8507                    let handle = self.table(table)?;
8508                    let rows = match control {
8509                        Some(control) => {
8510                            handle.lock().visible_rows_controlled(snapshot, control)?
8511                        }
8512                        None => handle.lock().visible_rows(snapshot)?,
8513                    };
8514                    let mut matched = Vec::new();
8515                    for (row_index, row) in rows.into_iter().enumerate() {
8516                        commit_prepare_checkpoint(control, row_index)?;
8517                        let image = TriggerRowImage::from_row(row);
8518                        let passes = conditions
8519                            .iter()
8520                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8521                            .collect::<Result<Vec<_>>>()?
8522                            .into_iter()
8523                            .all(|b| b);
8524                        if passes {
8525                            matched.push(image);
8526                        }
8527                    }
8528                    if let Some(pk) = schema.primary_key() {
8529                        matched.sort_by(|a, b| {
8530                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
8531                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
8532                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
8533                        });
8534                    }
8535                    select_results.insert(id.clone(), matched);
8536                }
8537                TriggerStep::Foreach { id, steps } => {
8538                    let rows = select_results.get(id).ok_or_else(|| {
8539                        MongrelError::InvalidArgument(format!(
8540                            "trigger {:?} foreach references unknown select id {id:?}",
8541                            trigger.name
8542                        ))
8543                    })?;
8544                    if rows.len() > config.max_loop_iterations as usize {
8545                        return Err(MongrelError::InvalidArgument(format!(
8546                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
8547                            trigger.name, config.max_loop_iterations
8548                        )));
8549                    }
8550                    for (row_index, row) in rows.clone().into_iter().enumerate() {
8551                        commit_prepare_checkpoint(control, row_index)?;
8552                        let result = self.execute_trigger_steps(
8553                            trigger,
8554                            steps,
8555                            event,
8556                            staging.as_deref_mut(),
8557                            out,
8558                            trigger_stack,
8559                            config,
8560                            select_results,
8561                            depth + 1,
8562                            Some(&row),
8563                            read_epoch,
8564                            control,
8565                        )?;
8566                        if result == TriggerProgramOutcome::Ignore {
8567                            return Ok(TriggerProgramOutcome::Ignore);
8568                        }
8569                    }
8570                }
8571                TriggerStep::DeleteWhere { table, conditions } => {
8572                    let schema = self.table(table)?.lock().schema().clone();
8573                    let snapshot = Snapshot::at(read_epoch);
8574                    let handle = self.table(table)?;
8575                    let rows = match control {
8576                        Some(control) => {
8577                            handle.lock().visible_rows_controlled(snapshot, control)?
8578                        }
8579                        None => handle.lock().visible_rows(snapshot)?,
8580                    };
8581                    let table_id = self.table_id(table)?;
8582                    let mut to_delete = Vec::new();
8583                    for (row_index, row) in rows.into_iter().enumerate() {
8584                        commit_prepare_checkpoint(control, row_index)?;
8585                        let image = TriggerRowImage::from_row(row.clone());
8586                        let passes = conditions
8587                            .iter()
8588                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8589                            .collect::<Result<Vec<_>>>()?
8590                            .into_iter()
8591                            .all(|b| b);
8592                        if passes {
8593                            to_delete.push((table_id, row.row_id));
8594                        }
8595                    }
8596                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
8597                        commit_prepare_checkpoint(control, row_index)?;
8598                        out.added
8599                            .push((table_id, crate::txn::Staged::Delete(row_id)));
8600                        out.added_stacks.push(trigger_stack.to_vec());
8601                    }
8602                }
8603                TriggerStep::UpdateWhere {
8604                    table,
8605                    conditions,
8606                    cells,
8607                } => {
8608                    let schema = self.table(table)?.lock().schema().clone();
8609                    let snapshot = Snapshot::at(read_epoch);
8610                    let handle = self.table(table)?;
8611                    let rows = match control {
8612                        Some(control) => {
8613                            handle.lock().visible_rows_controlled(snapshot, control)?
8614                        }
8615                        None => handle.lock().visible_rows(snapshot)?,
8616                    };
8617                    let table_id = self.table_id(table)?;
8618                    let mut changed_columns =
8619                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
8620                    changed_columns.sort_unstable();
8621                    changed_columns.dedup();
8622                    let mut to_update = Vec::new();
8623                    for (row_index, row) in rows.into_iter().enumerate() {
8624                        commit_prepare_checkpoint(control, row_index)?;
8625                        let image = TriggerRowImage::from_row(row.clone());
8626                        let passes = conditions
8627                            .iter()
8628                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8629                            .collect::<Result<Vec<_>>>()?
8630                            .into_iter()
8631                            .all(|b| b);
8632                        if passes {
8633                            let new_cells = cells
8634                                .iter()
8635                                .map(|cell| {
8636                                    Ok((
8637                                        cell.column_id,
8638                                        eval_trigger_value(&cell.value, event, Some(&image))?,
8639                                    ))
8640                                })
8641                                .collect::<Result<Vec<_>>>()?;
8642                            let mut merged = row.columns.clone();
8643                            for (column_id, value) in new_cells {
8644                                merged.insert(column_id, value);
8645                            }
8646                            to_update.push((table_id, row.row_id, merged));
8647                        }
8648                    }
8649                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
8650                    {
8651                        commit_prepare_checkpoint(control, row_index)?;
8652                        out.added.push((
8653                            table_id,
8654                            crate::txn::Staged::Update {
8655                                row_id,
8656                                new_row: merged.into_iter().collect(),
8657                                changed_columns: changed_columns.clone(),
8658                            },
8659                        ));
8660                        out.added_stacks.push(trigger_stack.to_vec());
8661                    }
8662                }
8663                TriggerStep::Raise { action, message } => match action {
8664                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
8665                    TriggerRaiseAction::Abort
8666                    | TriggerRaiseAction::Fail
8667                    | TriggerRaiseAction::Rollback => {
8668                        let message = eval_trigger_value(message, event, selected)?;
8669                        return Err(MongrelError::TriggerValidation(format!(
8670                            "trigger {:?} raised: {}; trigger stack: {}",
8671                            trigger.name,
8672                            trigger_message(message),
8673                            Self::format_trigger_stack(trigger_stack)
8674                        )));
8675                    }
8676                },
8677            }
8678        }
8679        Ok(TriggerProgramOutcome::Continue)
8680    }
8681
8682    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
8683        let Some(stacks) = stacks else {
8684            return Vec::new();
8685        };
8686        let mut out = Vec::new();
8687        for idx in indices {
8688            let Some(stack) = stacks.get(*idx) else {
8689                continue;
8690            };
8691            for name in stack {
8692                if !out.iter().any(|existing| existing == name) {
8693                    out.push(name.clone());
8694                }
8695            }
8696        }
8697        out
8698    }
8699
8700    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
8701        let mut out = stack.to_vec();
8702        out.push(trigger_name.to_string());
8703        out
8704    }
8705
8706    fn format_trigger_stack(stack: &[String]) -> String {
8707        if stack.is_empty() {
8708            "<root>".into()
8709        } else {
8710            stack.join(" -> ")
8711        }
8712    }
8713
8714    /// Authoritatively validate every declared constraint on the staged write
8715    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
8716    /// SET NULL actions into explicit child ops. Called from
8717    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
8718    /// violation as an `Err`, aborting the commit atomically. This is the
8719    /// server-side authority point: concurrent remote writers that each pass
8720    /// their own client-side checks still cannot both commit a violating batch.
8721    ///
8722    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
8723    /// intra-transaction dedup; concurrent-txn races are additionally caught by
8724    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
8725    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
8726    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
8727    /// RESTRICT-only (cascade-truncate is unsupported).
8728    fn validate_constraints(
8729        &self,
8730        staging: &mut Vec<(u64, crate::txn::Staged)>,
8731        read_epoch: Epoch,
8732        control: Option<&crate::ExecutionControl>,
8733    ) -> Result<()> {
8734        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
8735        use crate::memtable::Row;
8736        use crate::txn::Staged;
8737        use std::collections::HashSet;
8738
8739        commit_prepare_checkpoint(control, 0)?;
8740        let snapshot = Snapshot::at(read_epoch);
8741        let cat = self.catalog.read();
8742
8743        // Collect live (id, name, constraints-bearing?) for staged tables.
8744        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
8745            .tables
8746            .iter()
8747            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
8748            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
8749            .collect();
8750
8751        // Fast path: bail if no live table declares any constraints at all.
8752        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
8753        if !any_constraints {
8754            return Ok(());
8755        }
8756
8757        // Lazily-loaded visible rows per table, shared across checks.
8758        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
8759        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
8760            if let Some(r) = rows_cache.get(&table_id) {
8761                return Ok(r.clone());
8762            }
8763            let handle = self.table_by_id(table_id)?;
8764            let rows = match control {
8765                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
8766                None => handle.lock().visible_rows(snapshot)?,
8767            };
8768            rows_cache.insert(table_id, rows.clone());
8769            Ok(rows)
8770        };
8771
8772        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
8773        // carry an explicit old RowId + full new image. This makes action choice
8774        // reliable even when the referenced key itself changes; a delete+put
8775        // heuristic cannot distinguish that from unrelated operations.
8776        let mut processed_updates = HashSet::new();
8777        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
8778        let mut update_pass = 0_usize;
8779        loop {
8780            commit_prepare_checkpoint(control, update_pass)?;
8781            update_pass += 1;
8782            let updates: Vec<PendingUpdate> = staging
8783                .iter()
8784                .enumerate()
8785                .filter_map(|(index, (table_id, op))| match op {
8786                    Staged::Update {
8787                        row_id,
8788                        new_row: cells,
8789                        ..
8790                    } if !processed_updates.contains(&index) => {
8791                        Some((index, *table_id, *row_id, cells.clone()))
8792                    }
8793                    _ => None,
8794                })
8795                .collect();
8796            if updates.is_empty() {
8797                break;
8798            }
8799            let mut new_ops = Vec::new();
8800            for (update_index, (index, table_id, row_id, new_cells)) in
8801                updates.into_iter().enumerate()
8802            {
8803                commit_prepare_checkpoint(control, update_index)?;
8804                processed_updates.insert(index);
8805                let Some(tname) = live
8806                    .iter()
8807                    .find(|(id, _, _)| *id == table_id)
8808                    .map(|(_, name, _)| *name)
8809                else {
8810                    continue;
8811                };
8812                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
8813                    continue;
8814                };
8815                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
8816                for (child_id, _child_name, child_schema) in &live {
8817                    for fk in &child_schema.constraints.foreign_keys {
8818                        if fk.ref_table != tname {
8819                            continue;
8820                        }
8821                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
8822                        else {
8823                            continue;
8824                        };
8825                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
8826                            == Some(old_key.as_slice())
8827                        {
8828                            continue;
8829                        }
8830                        if fk.on_update == FkAction::Restrict {
8831                            continue;
8832                        }
8833                        let child_rows = load_rows(*child_id)?;
8834                        for (child_index, child) in child_rows.into_iter().enumerate() {
8835                            commit_prepare_checkpoint(control, child_index)?;
8836                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
8837                                != Some(old_key.as_slice())
8838                            {
8839                                continue;
8840                            }
8841                            if staging.iter().any(|(id, op)| {
8842                                *id == *child_id
8843                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
8844                            }) {
8845                                continue;
8846                            }
8847                            let mut cells: Vec<(u16, Value)> = child
8848                                .columns
8849                                .iter()
8850                                .map(|(column_id, value)| (*column_id, value.clone()))
8851                                .collect();
8852                            for (child_column, parent_column) in
8853                                fk.columns.iter().zip(&fk.ref_columns)
8854                            {
8855                                cells.retain(|(column_id, _)| column_id != child_column);
8856                                let value = match fk.on_update {
8857                                    FkAction::Cascade => {
8858                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
8859                                    }
8860                                    FkAction::SetNull => Value::Null,
8861                                    FkAction::Restrict => {
8862                                        return Err(MongrelError::Other(
8863                                            "restricted foreign-key update reached cascade preparation"
8864                                                .into(),
8865                                        ));
8866                                    }
8867                                };
8868                                cells.push((*child_column, value));
8869                            }
8870                            cells.sort_by_key(|(column_id, _)| *column_id);
8871                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
8872                                *id == *child_id
8873                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
8874                            }) {
8875                                if let Staged::Update {
8876                                    new_row: existing,
8877                                    changed_columns,
8878                                    ..
8879                                } = &mut staging[existing_index].1 {
8880                                    changed_columns.extend(fk.columns.iter().copied());
8881                                    changed_columns.sort_unstable();
8882                                    changed_columns.dedup();
8883                                    if *existing != cells {
8884                                        *existing = cells;
8885                                        processed_updates.remove(&existing_index);
8886                                    }
8887                                }
8888                            } else {
8889                                new_ops.push((
8890                                    *child_id,
8891                                    Staged::Update {
8892                                        row_id: child.row_id,
8893                                        new_row: cells,
8894                                        changed_columns: fk.columns.clone(),
8895                                    },
8896                                ));
8897                            }
8898                        }
8899                    }
8900                }
8901            }
8902            staging.extend(new_ops);
8903        }
8904
8905        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
8906        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
8907        // enforced as a violation in Phase B. `cascaded` records every delete
8908        // we have already expanded so a self-referential CASCADE FK cannot loop.
8909        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
8910        let mut cascade_pass = 0_usize;
8911        loop {
8912            commit_prepare_checkpoint(control, cascade_pass)?;
8913            cascade_pass += 1;
8914            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
8915            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
8916                .iter()
8917                .filter_map(|(t, op)| match op {
8918                    Staged::Delete(rid) => Some((*t, *rid)),
8919                    _ => None,
8920                })
8921                .collect();
8922            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
8923                commit_prepare_checkpoint(control, delete_index)?;
8924                if !cascaded.insert((table_id, rid.0)) {
8925                    continue;
8926                }
8927                let Some(tname) = live
8928                    .iter()
8929                    .find(|(t, _, _)| *t == table_id)
8930                    .map(|(_, n, _)| *n)
8931                else {
8932                    continue;
8933                };
8934                let parent_handle = self.table_by_id(table_id)?;
8935                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
8936                    continue;
8937                };
8938                for (child_id, _child_name, child_schema) in &live {
8939                    for fk in &child_schema.constraints.foreign_keys {
8940                        if fk.ref_table != tname {
8941                            continue;
8942                        }
8943                        let Some(parent_key) =
8944                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
8945                        else {
8946                            continue;
8947                        };
8948                        // Suppress ON DELETE cascade/set-null when this "delete"
8949                        // is actually half of an UPDATE encoded as Delete(old)+
8950                        // Put(new): if a staged Put in the SAME table still
8951                        // provides the referenced parent key, the parent still
8952                        // exists (its non-key columns changed) and the children
8953                        // must be left alone. A genuine delete, or an update
8954                        // that CHANGES the referenced key, has no preserving Put
8955                        // → cascade fires as before.
8956                        let key_preserved = staging.iter().any(|(t, op)| {
8957                            if *t != table_id {
8958                                return false;
8959                            }
8960                            let Staged::Put(cells) = op else {
8961                                return false;
8962                            };
8963                            let map: HashMap<u16, crate::memtable::Value> =
8964                                cells.iter().cloned().collect();
8965                            encode_composite_key(&fk.ref_columns, &map).as_deref()
8966                                == Some(parent_key.as_slice())
8967                        });
8968                        if key_preserved {
8969                            continue;
8970                        }
8971                        match fk.on_delete {
8972                            FkAction::Restrict => continue,
8973                            FkAction::Cascade => {
8974                                let child_rows = load_rows(*child_id)?;
8975                                for (child_index, cr) in child_rows.iter().enumerate() {
8976                                    commit_prepare_checkpoint(control, child_index)?;
8977                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8978                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8979                                            == Some(parent_key.as_slice())
8980                                    {
8981                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
8982                                    }
8983                                }
8984                            }
8985                            FkAction::SetNull => {
8986                                let child_rows = load_rows(*child_id)?;
8987                                for (child_index, cr) in child_rows.iter().enumerate() {
8988                                    commit_prepare_checkpoint(control, child_index)?;
8989                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8990                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8991                                            == Some(parent_key.as_slice())
8992                                    {
8993                                        // Re-emit the child row with the FK
8994                                        // columns set to NULL (delete + put).
8995                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
8996                                            .columns
8997                                            .iter()
8998                                            .map(|(k, v)| (*k, v.clone()))
8999                                            .collect();
9000                                        for cid in &fk.columns {
9001                                            cells.retain(|(k, _)| k != cid);
9002                                            cells.push((*cid, crate::memtable::Value::Null));
9003                                        }
9004                                        new_ops.push((
9005                                            *child_id,
9006                                            Staged::Update {
9007                                                row_id: cr.row_id,
9008                                                new_row: cells,
9009                                                changed_columns: fk.columns.clone(),
9010                                            },
9011                                        ));
9012                                    }
9013                                }
9014                            }
9015                        }
9016                    }
9017                }
9018            }
9019            if new_ops.is_empty() {
9020                break;
9021            }
9022            staging.extend(new_ops);
9023        }
9024
9025        // Rows staged for deletion in THIS transaction (now including cascaded
9026        // deletes). Used to exclude the old version of an updated row from
9027        // unique-existence scans.
9028        let staged_deletes: HashSet<(u64, u64)> = staging
9029            .iter()
9030            .filter_map(|(t, op)| match op {
9031                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
9032                _ => None,
9033            })
9034            .collect();
9035
9036        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
9037        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
9038
9039        // ── Phase B: validate the fully-expanded staging set.
9040        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
9041            commit_prepare_checkpoint(control, operation_index)?;
9042            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
9043            else {
9044                continue;
9045            };
9046            let cells_map: HashMap<u16, crate::memtable::Value>;
9047            match op {
9048                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
9049                    cells_map = cells.iter().cloned().collect();
9050
9051                    // CHECK constraints.
9052                    if !schema.constraints.checks.is_empty() {
9053                        validate_checks(&schema.constraints.checks, &cells_map)?;
9054                    }
9055
9056                    // UNIQUE (non-PK) constraints.
9057                    for uc in &schema.constraints.uniques {
9058                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
9059                            continue; // NULL in a constrained column → skip (SQL).
9060                        };
9061                        let marker = (*table_id, uc.id, key.clone());
9062                        if !seen_unique.insert(marker) {
9063                            return Err(MongrelError::Conflict(format!(
9064                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
9065                                uc.name
9066                            )));
9067                        }
9068                        let rows = load_rows(*table_id)?;
9069                        for (row_index, r) in rows.iter().enumerate() {
9070                            commit_prepare_checkpoint(control, row_index)?;
9071                            // Skip rows this same transaction is deleting (the
9072                            // old version of an updated/cascade-deleted row).
9073                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
9074                                continue;
9075                            }
9076                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
9077                                if theirs == key {
9078                                    return Err(MongrelError::Conflict(format!(
9079                                        "UNIQUE constraint '{}' on table '{tname}' violated",
9080                                        uc.name
9081                                    )));
9082                                }
9083                            }
9084                        }
9085                    }
9086
9087                    // FK insert-side: parent must exist.
9088                    for fk in &schema.constraints.foreign_keys {
9089                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
9090                            continue; // NULL FK component → not checked (SQL).
9091                        };
9092                        let Some(parent_id) = cat
9093                            .tables
9094                            .iter()
9095                            .find(|t| t.name == fk.ref_table)
9096                            .map(|t| t.table_id)
9097                        else {
9098                            return Err(MongrelError::InvalidArgument(format!(
9099                                "FOREIGN KEY '{}' references unknown table '{}'",
9100                                fk.name, fk.ref_table
9101                            )));
9102                        };
9103                        let parent_rows = load_rows(parent_id)?;
9104                        let mut found = false;
9105                        for (row_index, r) in parent_rows.iter().enumerate() {
9106                            commit_prepare_checkpoint(control, row_index)?;
9107                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
9108                                continue;
9109                            }
9110                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
9111                                if pkey == child_key {
9112                                    found = true;
9113                                    break;
9114                                }
9115                            }
9116                        }
9117                        // Final-write-set FK validation: a parent inserted in
9118                        // THIS transaction also satisfies the FK. This enables
9119                        // atomic parent+child batches and cyclical/mutual FK
9120                        // inserts within a single transaction — the child sees
9121                        // the staged parent put even though it is not committed
9122                        // yet.
9123                        if !found {
9124                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
9125                                commit_prepare_checkpoint(control, staged_index)?;
9126                                if *st_table != parent_id {
9127                                    continue;
9128                                }
9129                                if let Staged::Put(pcells)
9130                                | Staged::Update {
9131                                    new_row: pcells, ..
9132                                } = st_op
9133                                {
9134                                    let pmap: HashMap<u16, crate::memtable::Value> =
9135                                        pcells.iter().cloned().collect();
9136                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
9137                                    {
9138                                        if pkey == child_key {
9139                                            found = true;
9140                                            break;
9141                                        }
9142                                    }
9143                                }
9144                            }
9145                        }
9146                        if !found {
9147                            return Err(MongrelError::Conflict(format!(
9148                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
9149                                fk.name, fk.ref_table
9150                            )));
9151                        }
9152                    }
9153
9154                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
9155                    // expanded in Phase A; here the final child write set is
9156                    // known, so a child explicitly moved/deleted by this same
9157                    // transaction does not cause a false violation.
9158                    if let Staged::Update { row_id, .. } = op {
9159                        let parent_handle = self.table_by_id(*table_id)?;
9160                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
9161                            continue;
9162                        };
9163                        for (child_id, child_name, child_schema) in &live {
9164                            for fk in &child_schema.constraints.foreign_keys {
9165                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
9166                                    continue;
9167                                }
9168                                let Some(old_key) =
9169                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
9170                                else {
9171                                    continue;
9172                                };
9173                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
9174                                    == Some(old_key.as_slice())
9175                                {
9176                                    continue;
9177                                }
9178                                for (child_index, child) in
9179                                    load_rows(*child_id)?.into_iter().enumerate()
9180                                {
9181                                    commit_prepare_checkpoint(control, child_index)?;
9182                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
9183                                        != Some(old_key.as_slice())
9184                                    {
9185                                        continue;
9186                                    }
9187                                    let replacement = staging.iter().find_map(|(id, op)| {
9188                                        if *id != *child_id {
9189                                            return None;
9190                                        }
9191                                        match op {
9192                                            Staged::Delete(id) if *id == child.row_id => Some(None),
9193                                            Staged::Update {
9194                                                row_id,
9195                                                new_row: cells,
9196                                                ..
9197                                            } if *row_id == child.row_id => {
9198                                                let map: HashMap<u16, Value> =
9199                                                    cells.iter().cloned().collect();
9200                                                Some(encode_composite_key(&fk.columns, &map))
9201                                            }
9202                                            _ => None,
9203                                        }
9204                                    });
9205                                    if replacement.is_some_and(|key| {
9206                                        key.as_deref() != Some(old_key.as_slice())
9207                                    }) {
9208                                        continue;
9209                                    }
9210                                    return Err(MongrelError::Conflict(format!(
9211                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
9212                                        fk.name
9213                                    )));
9214                                }
9215                            }
9216                        }
9217                    }
9218                }
9219                Staged::Delete(rid) => {
9220                    // FK ON DELETE RESTRICT: a child row (whose FK action is
9221                    // RESTRICT) referencing this parent blocks the delete.
9222                    // CASCADE/SET NULL children were expanded in Phase A.
9223                    let parent_handle = self.table_by_id(*table_id)?;
9224                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
9225                        continue;
9226                    };
9227                    for (child_id, child_name, child_schema) in &live {
9228                        for fk in &child_schema.constraints.foreign_keys {
9229                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
9230                                continue;
9231                            }
9232                            let Some(parent_key) =
9233                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
9234                            else {
9235                                continue;
9236                            };
9237                            let child_rows = load_rows(*child_id)?;
9238                            for (row_index, r) in child_rows.iter().enumerate() {
9239                                commit_prepare_checkpoint(control, row_index)?;
9240                                // A child already being deleted by this txn
9241                                // (cascade/inline) is not a restrict violation.
9242                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
9243                                    continue;
9244                                }
9245                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
9246                                    if ck == parent_key {
9247                                        return Err(MongrelError::Conflict(format!(
9248                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
9249                                            fk.name
9250                                        )));
9251                                    }
9252                                }
9253                            }
9254                        }
9255                    }
9256                }
9257                Staged::Truncate => {
9258                    // Truncate is RESTRICT-only: reject if any child references
9259                    // this table (any FK action), since cascade-truncate is
9260                    // unsupported.
9261                    for (child_id, child_name, child_schema) in &live {
9262                        for fk in &child_schema.constraints.foreign_keys {
9263                            if fk.ref_table != tname {
9264                                continue;
9265                            }
9266                            let child_rows = load_rows(*child_id)?;
9267                            if child_rows
9268                                .iter()
9269                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
9270                            {
9271                                return Err(MongrelError::Conflict(format!(
9272                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
9273                                    fk.name
9274                                )));
9275                            }
9276                        }
9277                    }
9278                }
9279            }
9280        }
9281        Ok(())
9282    }
9283
9284    fn validate_write_permissions(
9285        &self,
9286        staging: &[(u64, crate::txn::Staged)],
9287        principal: Option<&crate::auth::Principal>,
9288        control: Option<&crate::ExecutionControl>,
9289    ) -> Result<()> {
9290        commit_prepare_checkpoint(control, 0)?;
9291        if principal.is_none() && !self.auth_state.require_auth() {
9292            return Ok(());
9293        }
9294        let principal = principal.ok_or(MongrelError::AuthRequired)?;
9295        let needs = summarize_write_permissions(staging);
9296        let catalog = self.catalog.read();
9297
9298        if needs.values().any(|need| need.truncate) {
9299            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
9300        }
9301        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
9302            commit_prepare_checkpoint(control, need_index)?;
9303            let entry = catalog
9304                .tables
9305                .iter()
9306                .find(|entry| {
9307                    entry.table_id == table_id
9308                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
9309                })
9310                .ok_or_else(|| {
9311                    MongrelError::NotFound(format!(
9312                        "live table {table_id} not found during write validation"
9313                    ))
9314                })?;
9315            if matches!(entry.state, TableState::Building { .. }) {
9316                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
9317                continue;
9318            }
9319            if need.insert {
9320                Self::require_columns_for_principal(
9321                    &entry.name,
9322                    &entry.schema,
9323                    crate::auth::ColumnOperation::Insert,
9324                    &need.insert_columns,
9325                    principal,
9326                )?;
9327            }
9328            if need.update {
9329                Self::require_columns_for_principal(
9330                    &entry.name,
9331                    &entry.schema,
9332                    crate::auth::ColumnOperation::Update,
9333                    &need.update_columns,
9334                    principal,
9335                )?;
9336            }
9337            if need.delete {
9338                self.require_for(
9339                    Some(principal),
9340                    &crate::auth::Permission::Delete {
9341                        table: entry.name.clone(),
9342                    },
9343                )?;
9344            }
9345        }
9346        Ok(())
9347    }
9348
9349    fn validate_security_writes(
9350        &self,
9351        staging: &[(u64, crate::txn::Staged)],
9352        read_epoch: Epoch,
9353        explicit_principal: Option<&crate::auth::Principal>,
9354        control: Option<&crate::ExecutionControl>,
9355    ) -> Result<()> {
9356        commit_prepare_checkpoint(control, 0)?;
9357        use crate::security::PolicyCommand;
9358        use crate::txn::Staged;
9359
9360        let catalog = self.catalog.read();
9361        if catalog.security.rls_tables.is_empty() {
9362            return Ok(());
9363        }
9364        let security = catalog.security.clone();
9365        let table_names = catalog
9366            .tables
9367            .iter()
9368            .filter(|entry| matches!(entry.state, TableState::Live))
9369            .map(|entry| (entry.table_id, entry.name.clone()))
9370            .collect::<HashMap<_, _>>();
9371        drop(catalog);
9372        if !staging.iter().any(|(table_id, _)| {
9373            table_names
9374                .get(table_id)
9375                .is_some_and(|table| security.rls_enabled(table))
9376        }) {
9377            return Ok(());
9378        }
9379        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
9380
9381        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
9382            commit_prepare_checkpoint(control, operation_index)?;
9383            let Some(table) = table_names.get(table_id) else {
9384                continue;
9385            };
9386            if !security.rls_enabled(table) || principal.is_admin {
9387                continue;
9388            }
9389            let denied = |command| MongrelError::PermissionDenied {
9390                required: match command {
9391                    PolicyCommand::Insert => crate::auth::Permission::Insert {
9392                        table: table.clone(),
9393                    },
9394                    PolicyCommand::Update => crate::auth::Permission::Update {
9395                        table: table.clone(),
9396                    },
9397                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
9398                        crate::auth::Permission::Delete {
9399                            table: table.clone(),
9400                        }
9401                    }
9402                },
9403                principal: principal.username.clone(),
9404            };
9405            match operation {
9406                Staged::Put(cells) => {
9407                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
9408                    row.columns.extend(cells.iter().cloned());
9409                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
9410                        return Err(denied(PolicyCommand::Insert));
9411                    }
9412                }
9413                Staged::Update {
9414                    row_id,
9415                    new_row: cells,
9416                    ..
9417                } => {
9418                    let old = self
9419                        .table_by_id(*table_id)?
9420                        .lock()
9421                        .get(*row_id, Snapshot::at(read_epoch))
9422                        .ok_or_else(|| {
9423                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9424                        })?;
9425                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
9426                        return Err(denied(PolicyCommand::Update));
9427                    }
9428                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
9429                    new.columns.extend(cells.iter().cloned());
9430                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
9431                        return Err(denied(PolicyCommand::Update));
9432                    }
9433                }
9434                Staged::Delete(row_id) => {
9435                    let old = self
9436                        .table_by_id(*table_id)?
9437                        .lock()
9438                        .get(*row_id, Snapshot::at(read_epoch))
9439                        .ok_or_else(|| {
9440                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9441                        })?;
9442                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
9443                        return Err(denied(PolicyCommand::Delete));
9444                    }
9445                }
9446                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
9447            }
9448        }
9449        Ok(())
9450    }
9451
9452    /// Seal a transaction (spec §9.3):
9453    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
9454    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
9455    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
9456    ///    group-sync, record conflict keys.
9457    /// 3. Publish — apply to tables, advance visible in-order.
9458    #[allow(clippy::too_many_arguments)]
9459    pub(crate) fn commit_transaction_with_external_states(
9460        &self,
9461        txn_id: u64,
9462        read_epoch: Epoch,
9463        staging: Vec<(u64, crate::txn::Staged)>,
9464        external_states: Vec<(String, Vec<u8>)>,
9465        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9466        security_principal: Option<crate::auth::Principal>,
9467        principal_catalog_bound: bool,
9468        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9469    ) -> Result<(Epoch, Vec<RowId>)> {
9470        self.commit_transaction_with_external_states_inner(
9471            txn_id,
9472            read_epoch,
9473            staging,
9474            external_states,
9475            materialized_view_updates,
9476            security_principal,
9477            principal_catalog_bound,
9478            external_trigger_bridge,
9479            None,
9480            None,
9481        )
9482    }
9483
9484    #[allow(clippy::too_many_arguments)]
9485    pub(crate) fn commit_transaction_with_external_states_controlled(
9486        &self,
9487        txn_id: u64,
9488        read_epoch: Epoch,
9489        staging: Vec<(u64, crate::txn::Staged)>,
9490        external_states: Vec<(String, Vec<u8>)>,
9491        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9492        security_principal: Option<crate::auth::Principal>,
9493        principal_catalog_bound: bool,
9494        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9495        control: &crate::ExecutionControl,
9496        before_commit: &mut dyn FnMut() -> Result<()>,
9497    ) -> Result<(Epoch, Vec<RowId>)> {
9498        self.commit_transaction_with_external_states_inner(
9499            txn_id,
9500            read_epoch,
9501            staging,
9502            external_states,
9503            materialized_view_updates,
9504            security_principal,
9505            principal_catalog_bound,
9506            external_trigger_bridge,
9507            Some(control),
9508            Some(before_commit),
9509        )
9510    }
9511
9512    #[allow(clippy::too_many_arguments)]
9513    fn commit_transaction_with_external_states_inner(
9514        &self,
9515        txn_id: u64,
9516        read_epoch: Epoch,
9517        mut staging: Vec<(u64, crate::txn::Staged)>,
9518        external_states: Vec<(String, Vec<u8>)>,
9519        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9520        mut security_principal: Option<crate::auth::Principal>,
9521        principal_catalog_bound: bool,
9522        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9523        control: Option<&crate::ExecutionControl>,
9524        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
9525    ) -> Result<(Epoch, Vec<RowId>)> {
9526        use crate::memtable::Row;
9527        use crate::txn::{Staged, StagedOp, WriteKey};
9528        use crate::wal::Op;
9529        use std::collections::hash_map::DefaultHasher;
9530        use std::hash::{Hash, Hasher};
9531        use std::sync::atomic::Ordering;
9532
9533        if txn_id == crate::wal::SYSTEM_TXN_ID {
9534            return Err(MongrelError::Full(
9535                "per-open transaction id namespace exhausted; reopen the database".into(),
9536            ));
9537        }
9538        if self.read_only {
9539            return Err(MongrelError::ReadOnlyReplica);
9540        }
9541        commit_prepare_checkpoint(control, 0)?;
9542        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
9543        self.refresh_security_catalog_if_stale(observed_security_version)?;
9544        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
9545        if self.auth_state.require_auth() && security_principal.is_none() {
9546            return Err(MongrelError::AuthRequired);
9547        }
9548        {
9549            let catalog = self.catalog.read();
9550            if catalog.require_auth
9551                || principal_catalog_bound
9552                || security_principal
9553                    .as_ref()
9554                    .is_some_and(|principal| principal.user_id != 0)
9555            {
9556                let principal = security_principal
9557                    .as_ref()
9558                    .ok_or(MongrelError::AuthRequired)?;
9559                security_principal =
9560                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
9561                if security_principal.is_none() {
9562                    return Err(MongrelError::AuthRequired);
9563                }
9564            }
9565        }
9566        let _replication_guard = self.replication_barrier.read();
9567        if self.poisoned.load(Ordering::Relaxed) {
9568            return Err(MongrelError::Other(
9569                "database poisoned by fsync error".into(),
9570            ));
9571        }
9572        let mut external_states = dedup_external_states(external_states);
9573        if !external_states.is_empty() {
9574            let cat = self.catalog.read();
9575            for (name, _) in &external_states {
9576                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
9577                    return Err(MongrelError::NotFound(format!(
9578                        "external table {name:?} not found"
9579                    )));
9580                }
9581            }
9582        }
9583        let prepared_materialized_views = {
9584            let mut deduplicated = HashMap::new();
9585            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
9586            {
9587                commit_prepare_checkpoint(control, definition_index)?;
9588                if definition.name.is_empty() || definition.query.trim().is_empty() {
9589                    return Err(MongrelError::InvalidArgument(
9590                        "materialized view name and query must not be empty".into(),
9591                    ));
9592                }
9593                deduplicated.insert(definition.name.clone(), definition);
9594            }
9595            let catalog = self.catalog.read();
9596            let mut prepared = Vec::with_capacity(deduplicated.len());
9597            for (definition_index, definition) in deduplicated.into_values().enumerate() {
9598                commit_prepare_checkpoint(control, definition_index)?;
9599                let table_id = catalog
9600                    .live(&definition.name)
9601                    .ok_or_else(|| {
9602                        MongrelError::NotFound(format!(
9603                            "materialized view table {:?} not found",
9604                            definition.name
9605                        ))
9606                    })?
9607                    .table_id;
9608                prepared.push((table_id, definition));
9609            }
9610            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
9611            prepared
9612        };
9613
9614        // ── 1. Prepare: fill generated values, expand triggers, validate, then
9615        // derive write keys from the final atomic write set.
9616        self.fill_auto_increment_for_staging(&mut staging, control)?;
9617        self.expand_table_triggers(
9618            &mut staging,
9619            read_epoch,
9620            external_trigger_bridge,
9621            &mut external_states,
9622            control,
9623        )?;
9624        self.fill_auto_increment_for_staging(&mut staging, control)?;
9625        external_states = dedup_external_states(external_states);
9626        let expected_external_generations = {
9627            let catalog = self.catalog.read();
9628            let mut generations = HashMap::with_capacity(external_states.len());
9629            for (name, _) in &external_states {
9630                let entry = catalog
9631                    .external_tables
9632                    .iter()
9633                    .find(|entry| entry.name == *name)
9634                    .ok_or_else(|| {
9635                        MongrelError::NotFound(format!("external table {name:?} not found"))
9636                    })?;
9637                generations.insert(name.clone(), entry.created_epoch);
9638            }
9639            generations
9640        };
9641
9642        // Validate declarative constraints (unique / FK / check) under the read
9643        // snapshot, outside the WAL mutex. Trigger-produced writes are included
9644        // here, so the batch either satisfies every declared constraint or is
9645        // rejected atomically.
9646        self.validate_constraints(&mut staging, read_epoch, control)?;
9647        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
9648        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
9649        let mut normalized = Vec::with_capacity(staging.len() * 2);
9650        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
9651            commit_prepare_checkpoint(control, staged_index)?;
9652            match op {
9653                crate::txn::Staged::Update {
9654                    row_id,
9655                    new_row: cells,
9656                    ..
9657                } => {
9658                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
9659                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
9660                }
9661                op => normalized.push((table_id, op)),
9662            }
9663        }
9664        staging = normalized;
9665        let has_changes = !staging.is_empty()
9666            || !external_states.is_empty()
9667            || !prepared_materialized_views.is_empty();
9668        let truncated_tables: HashSet<u64> = staging
9669            .iter()
9670            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
9671            .collect();
9672
9673        let write_keys = {
9674            let cat = self.catalog.read();
9675            let mut keys: Vec<WriteKey> = Vec::new();
9676            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9677                commit_prepare_checkpoint(control, staged_index)?;
9678                match staged {
9679                    Staged::Put(cells) => {
9680                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
9681                            for col in &entry.schema.columns {
9682                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
9683                                    if let Some((_, val)) =
9684                                        cells.iter().find(|(id, _)| *id == col.id)
9685                                    {
9686                                        let mut h = DefaultHasher::new();
9687                                        val.encode_key().hash(&mut h);
9688                                        keys.push(WriteKey::Unique {
9689                                            table_id: *table_id,
9690                                            index_id: 0,
9691                                            key_hash: h.finish(),
9692                                        });
9693                                    }
9694                                }
9695                            }
9696                            // Declared non-PK unique constraints register a
9697                            // `WriteKey::Unique` (namespace-separated from the
9698                            // PK's index_id==0 by setting the high bit) so two
9699                            // concurrent transactions inserting the same key
9700                            // cannot both commit. Rows with any NULL constrained
9701                            // column are skipped (SQL semantics).
9702                            for uc in &entry.schema.constraints.uniques {
9703                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
9704                                    &uc.columns,
9705                                    &cells.iter().cloned().collect(),
9706                                ) {
9707                                    let mut h = DefaultHasher::new();
9708                                    key_bytes.hash(&mut h);
9709                                    keys.push(WriteKey::Unique {
9710                                        table_id: *table_id,
9711                                        index_id: uc.id | 0x8000,
9712                                        key_hash: h.finish(),
9713                                    });
9714                                }
9715                            }
9716                        }
9717                    }
9718                    Staged::Delete(rid) => keys.push(WriteKey::Row {
9719                        table_id: *table_id,
9720                        row_id: rid.0,
9721                    }),
9722                    Staged::Truncate => keys.push(WriteKey::Table {
9723                        table_id: *table_id,
9724                    }),
9725                    Staged::Update { .. } => {
9726                        return Err(MongrelError::Other(
9727                            "transaction contains an unnormalized update during preparation".into(),
9728                        ));
9729                    }
9730                }
9731            }
9732            for (external_index, (name, _)) in external_states.iter().enumerate() {
9733                commit_prepare_checkpoint(control, external_index)?;
9734                let mut h = DefaultHasher::new();
9735                name.hash(&mut h);
9736                keys.push(WriteKey::Unique {
9737                    table_id: EXTERNAL_TABLE_ID,
9738                    index_id: 0,
9739                    key_hash: h.finish(),
9740                });
9741            }
9742            keys
9743        };
9744
9745        // Opportunistic pruning.
9746        let min_active = self.active_txns.min_read_epoch();
9747        if min_active < u64::MAX {
9748            self.conflicts.prune_below(Epoch(min_active));
9749        }
9750
9751        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
9752        // §8.5, review fix #17). Snapshot the conflict-index version so the
9753        // sequencer only re-checks if new commits arrived in the interim.
9754        if self.conflicts.conflicts(&write_keys, read_epoch) {
9755            return Err(MongrelError::Conflict(
9756                "write-write conflict (pre-validate, first-committer-wins)".into(),
9757            ));
9758        }
9759        let pre_validate_version = self.conflicts.version();
9760
9761        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
9762        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
9763        // streamed as Put records; they are linked at publish time.
9764        let mut spilled: Vec<SpilledRun> = Vec::new();
9765        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
9766        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
9767        // as the spill runs are live (registered on first spill, dropped at the
9768        // end of this function on commit/abort/error).
9769        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
9770        {
9771            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
9772            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
9773            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9774                commit_prepare_checkpoint(control, staged_index)?;
9775                if let Staged::Put(cells) = staged {
9776                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
9777                        bytes.saturating_add(value.estimated_bytes())
9778                    });
9779                    let table_bytes = table_bytes.entry(*table_id).or_default();
9780                    *table_bytes = table_bytes.saturating_add(bytes);
9781                    put_indexes.entry(*table_id).or_default().push(staged_index);
9782                }
9783            }
9784            let tables = self.tables.read();
9785            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
9786                commit_prepare_checkpoint(control, table_index)?;
9787                if bytes
9788                    <= self
9789                        .spill_threshold
9790                        .load(std::sync::atomic::Ordering::Relaxed)
9791                {
9792                    continue;
9793                }
9794                let Some(handle) = tables.get(&table_id) else {
9795                    continue;
9796                };
9797                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
9798                let mut t = handle.lock();
9799                let tdir = t.table_dir().to_path_buf();
9800                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
9801                std::fs::create_dir_all(&txn_dir)?;
9802                let run_id = t.alloc_run_id()? as u128;
9803                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
9804                let final_path = t.run_path(run_id as u64);
9805
9806                let mut rows: Vec<Row> = Vec::new();
9807                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
9808                    commit_prepare_checkpoint(control, put_index)?;
9809                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
9810                        return Err(MongrelError::Other(
9811                            "transaction put index no longer references a put".into(),
9812                        ));
9813                    };
9814                    t.validate_cells_not_null(cells)?;
9815                    let row_id = t.alloc_row_id()?;
9816                    let mut row = Row::new(row_id, Epoch(0));
9817                    row.columns.extend(std::mem::take(cells));
9818                    rows.push(row);
9819                }
9820                let schema = t.schema_ref().clone();
9821                let kek = t.kek_ref().cloned();
9822                let specs = t.indexable_column_specs();
9823                drop(t);
9824
9825                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
9826                    .uniform_epoch(true);
9827                if let Some(ref kek) = kek {
9828                    writer = writer.with_encryption(kek.as_ref(), specs);
9829                }
9830                commit_prepare_checkpoint(control, 0)?;
9831                let header = writer.write(&pending_path, &rows)?;
9832                commit_prepare_checkpoint(control, 0)?;
9833                let row_count = header.row_count;
9834                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
9835                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
9836
9837                spilled.push(SpilledRun {
9838                    table_id,
9839                    run_id,
9840                    pending_path,
9841                    final_path,
9842                    rows,
9843                    row_count,
9844                    min_rid,
9845                    max_rid,
9846                    content_hash: header.content_hash,
9847                });
9848                spilled_tables.insert(table_id);
9849            }
9850        }
9851
9852        // Test seam: let a test race `gc()` against this in-flight spill.
9853        if spill_guard.is_some() {
9854            if let Some(hook) = self.spill_hook.lock().as_ref() {
9855                hook();
9856            }
9857        }
9858
9859        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
9860        // Allocating row ids + building the rows here (lock order: table handle →
9861        // nothing) means the sequencer never locks a table handle while holding
9862        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
9863        // the table handle THEN the shared WAL; if the sequencer did the reverse
9864        // (WAL then handle) the two paths would deadlock (review fix: B1).
9865        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
9866        // Row ids are allocated here, before the sequencer's delta conflict
9867        // re-check, so a losing txn leaks the ids it reserved — harmless, the
9868        // u64 row-id space is monotonic and gaps are expected (spills do the same).
9869        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9870            .take(staging.len())
9871            .collect();
9872        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9873            .take(staging.len())
9874            .collect();
9875        {
9876            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
9877            for (index, (table_id, staged)) in staging.iter().enumerate() {
9878                commit_prepare_checkpoint(control, index)?;
9879                if matches!(staged, Staged::Delete(_))
9880                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
9881                {
9882                    indexes_by_table.entry(*table_id).or_default().push(index);
9883                }
9884            }
9885            let tables = self.tables.read();
9886            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
9887                commit_prepare_checkpoint(control, table_index)?;
9888                let handle = tables.get(&table_id).ok_or_else(|| {
9889                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9890                })?;
9891                #[cfg(test)]
9892                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
9893                let mut t = handle.lock();
9894                for (prepare_index, index) in indexes.into_iter().enumerate() {
9895                    commit_prepare_checkpoint(control, prepare_index)?;
9896                    match &staging[index].1 {
9897                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
9898                            t.validate_cells_not_null(cells)?;
9899                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
9900                            for (column, value) in cells {
9901                                row.columns.insert(*column, value.clone());
9902                            }
9903                            prebuilt[index] = Some(row);
9904                        }
9905                        Staged::Delete(row_id) => {
9906                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
9907                        }
9908                        Staged::Put(_) | Staged::Truncate => {}
9909                        Staged::Update { .. } => {
9910                            return Err(MongrelError::Other(
9911                                "transaction contains an unnormalized update during row preparation"
9912                                    .into(),
9913                            ));
9914                        }
9915                    }
9916                }
9917            }
9918        }
9919
9920        // Finish every fallible index read before the commit marker can become
9921        // durable. Post-durable row/run metadata application is then entirely
9922        // in-memory and cannot stop halfway through a multi-table publish.
9923        let prepared_table_handles = {
9924            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
9925            let put_table_ids: HashSet<u64> = staging
9926                .iter()
9927                .filter_map(|(table_id, staged)| {
9928                    matches!(staged, Staged::Put(_)).then_some(*table_id)
9929                })
9930                .collect();
9931            let tables = self.tables.read();
9932            let mut handles = HashMap::with_capacity(table_ids.len());
9933            for (table_index, table_id) in table_ids.into_iter().enumerate() {
9934                commit_prepare_checkpoint(control, table_index)?;
9935                let handle = tables.get(&table_id).ok_or_else(|| {
9936                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9937                })?;
9938                if put_table_ids.contains(&table_id) {
9939                    match control {
9940                        Some(control) => {
9941                            handle.lock().prepare_durable_publish_controlled(control)?
9942                        }
9943                        None => handle.lock().prepare_durable_publish()?,
9944                    }
9945                }
9946                handles.insert(table_id, handle.clone());
9947            }
9948            handles
9949        };
9950
9951        // Link large-transaction spill files before WAL durability. The guard
9952        // restores their pending names on every error before WAL append begins;
9953        // publication only attaches already-present files in memory.
9954        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
9955
9956        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
9957            .iter()
9958            .map(|run| {
9959                (
9960                    run.table_id,
9961                    run.rows.iter().map(|row| row.row_id).collect(),
9962                )
9963            })
9964            .collect();
9965        let committed_row_ids = staging
9966            .iter()
9967            .enumerate()
9968            .filter_map(|(index, (table_id, staged))| {
9969                if !matches!(staged, Staged::Put(_)) {
9970                    return None;
9971                }
9972                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
9973                    spilled_row_ids
9974                        .get_mut(table_id)
9975                        .and_then(VecDeque::pop_front)
9976                })
9977            })
9978            .collect();
9979
9980        let mut prepared_external = Vec::with_capacity(external_states.len());
9981        for (external_index, (name, state)) in external_states.iter().enumerate() {
9982            commit_prepare_checkpoint(control, external_index)?;
9983            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
9984            prepared_external.push((name.clone(), state.clone(), pending));
9985        }
9986
9987        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
9988        let added_runs: Vec<crate::wal::AddedRun> = spilled
9989            .iter()
9990            .map(|s| crate::wal::AddedRun {
9991                table_id: s.table_id,
9992                run_id: s.run_id,
9993                row_count: s.row_count,
9994                level: 0,
9995                min_row_id: s.min_rid,
9996                max_row_id: s.max_rid,
9997                content_hash: s.content_hash,
9998            })
9999            .collect();
10000        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
10001            hook();
10002        }
10003        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
10004        // Security mutations cannot overtake an authorized commit before its
10005        // commit marker is durable.
10006        let security_guard = self.security_coordinator.gate.read();
10007        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
10008            return Err(MongrelError::Conflict(
10009                "security policy changed during write".into(),
10010            ));
10011        }
10012        if spill_guard.is_some() {
10013            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
10014                hook();
10015            }
10016        }
10017        let commit_guard = self.commit_lock.lock();
10018        let catalog_generation_result = (|| {
10019            {
10020                let catalog = self.catalog.read();
10021                for table_id in prepared_table_handles.keys() {
10022                    let is_current = catalog.tables.iter().any(|entry| {
10023                        entry.table_id == *table_id
10024                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
10025                    });
10026                    if !is_current {
10027                        return Err(MongrelError::Conflict(format!(
10028                            "table {table_id} changed during transaction preparation"
10029                        )));
10030                    }
10031                }
10032                for (name, created_epoch) in &expected_external_generations {
10033                    let current = catalog
10034                        .external_tables
10035                        .iter()
10036                        .find(|entry| entry.name == *name)
10037                        .map(|entry| entry.created_epoch);
10038                    if current != Some(*created_epoch) {
10039                        return Err(MongrelError::Conflict(format!(
10040                            "external table {name:?} changed during transaction preparation"
10041                        )));
10042                    }
10043                }
10044                for (table_id, definition) in &prepared_materialized_views {
10045                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
10046                    if current != Some(*table_id) {
10047                        return Err(MongrelError::Conflict(format!(
10048                            "materialized view {:?} changed during transaction preparation",
10049                            definition.name
10050                        )));
10051                    }
10052                }
10053                if trigger_catalog_binding(&catalog) != trigger_binding {
10054                    return Err(MongrelError::Conflict(
10055                        "trigger or referenced table generation changed during transaction preparation"
10056                            .into(),
10057                    ));
10058                }
10059            }
10060            let tables = self.tables.read();
10061            for (table_id, prepared) in &prepared_table_handles {
10062                if !tables
10063                    .get(table_id)
10064                    .is_some_and(|current| current.ptr_eq(prepared))
10065                {
10066                    return Err(MongrelError::Conflict(format!(
10067                        "table {table_id} mount changed during transaction preparation"
10068                    )));
10069                }
10070            }
10071            Ok(())
10072        })();
10073        if let Err(error) = catalog_generation_result {
10074            drop(commit_guard);
10075            for (_, _, pending) in &prepared_external {
10076                let _ = std::fs::remove_file(pending);
10077            }
10078            return Err(error);
10079        }
10080        // The commit lock keeps the next epoch stable while logical spill
10081        // records are serialized. Build them before taking the shared WAL
10082        // lock, and cap their aggregate memory/WAL footprint.
10083        let new_epoch = self.epoch.assigned().next();
10084        let mut spilled_wal_bytes = 0;
10085        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
10086        let spill_prepare = (|| {
10087            for run in &mut spilled {
10088                for row in &mut run.rows {
10089                    row.committed_epoch = new_epoch;
10090                }
10091                for rows in encode_spilled_row_chunks(
10092                    &run.rows,
10093                    &mut spilled_wal_bytes,
10094                    SPILLED_WAL_TOTAL_MAX_BYTES,
10095                    control,
10096                )? {
10097                    spilled_wal_records.push((
10098                        run.table_id,
10099                        Op::SpilledRows {
10100                            table_id: run.table_id,
10101                            rows,
10102                        },
10103                    ));
10104                }
10105            }
10106            Result::<()>::Ok(())
10107        })();
10108        if let Err(error) = spill_prepare {
10109            for (_, _, pending) in &prepared_external {
10110                let _ = std::fs::remove_file(pending);
10111            }
10112            return Err(error);
10113        }
10114        let (new_epoch, mut _epoch_guard, applies, committed_materialized_views, commit_seq) = {
10115            let mut wal = self.shared_wal.lock();
10116
10117            // Re-check only if the conflict index advanced since pre-validation
10118            // (bounded delta — spec §8.5, review fix #17). If the version is
10119            // unchanged, the pre-check result is still valid and the sequencer
10120            // does O(1) work regardless of write-set size.
10121            if self.conflicts.version() != pre_validate_version
10122                && self.conflicts.conflicts(&write_keys, read_epoch)
10123            {
10124                // Abort: this txn assigned no epoch yet. The prepared-run guard
10125                // restores final run names to their pending paths on return.
10126                drop(wal);
10127                for (_, _, pending) in &prepared_external {
10128                    let _ = std::fs::remove_file(pending);
10129                }
10130                return Err(MongrelError::Conflict(
10131                    "write-write conflict (sequencer delta re-check)".into(),
10132                ));
10133            }
10134
10135            if let Some(control) = control {
10136                if let Err(error) = control.checkpoint() {
10137                    drop(wal);
10138                    for (_, _, pending) in &prepared_external {
10139                        let _ = std::fs::remove_file(pending);
10140                    }
10141                    return Err(error);
10142                }
10143            }
10144            let mut applies = Vec::<TableApplyBatch>::new();
10145            let mut apply_indexes = HashMap::<u64, usize>::new();
10146            let mut committed_materialized_views = Vec::new();
10147            let mut wal_records = spilled_wal_records;
10148
10149            let mut index = 0;
10150            while index < staging.len() {
10151                let table_id = staging[index].0;
10152                let handle = prepared_table_handles
10153                    .get(&table_id)
10154                    .cloned()
10155                    .ok_or_else(|| {
10156                        MongrelError::NotFound(format!("table {table_id} not prepared"))
10157                    })?;
10158                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
10159                    let index = applies.len();
10160                    applies.push(TableApplyBatch {
10161                        table_id,
10162                        handle,
10163                        ops: Vec::new(),
10164                    });
10165                    index
10166                });
10167
10168                // Skip puts for tables that were spilled — their data is in a
10169                // pending run, not in streamed Put records.
10170                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
10171                {
10172                    index += 1;
10173                    continue;
10174                }
10175
10176                match &staging[index].1 {
10177                    Staged::Put(_) => {
10178                        let mut rows = Vec::new();
10179                        while index < staging.len()
10180                            && staging[index].0 == table_id
10181                            && matches!(&staging[index].1, Staged::Put(_))
10182                        {
10183                            let mut row = prebuilt[index].take().ok_or_else(|| {
10184                                MongrelError::Other(
10185                                    "transaction prepare lost a prebuilt put row".into(),
10186                                )
10187                            })?;
10188                            row.committed_epoch = new_epoch;
10189                            rows.push(row);
10190                            index += 1;
10191                        }
10192                        let payload = bincode::serialize(&rows)
10193                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
10194                        wal_records.push((
10195                            table_id,
10196                            Op::Put {
10197                                table_id,
10198                                rows: payload,
10199                            },
10200                        ));
10201                        applies[batch_index].ops.push(StagedOp::Put(rows));
10202                    }
10203                    Staged::Delete(_) => {
10204                        let mut row_ids = Vec::new();
10205                        while index < staging.len()
10206                            && staging[index].0 == table_id
10207                            && matches!(&staging[index].1, Staged::Delete(_))
10208                        {
10209                            let Staged::Delete(row_id) = &staging[index].1 else {
10210                                return Err(MongrelError::Other(
10211                                    "transaction delete batch changed during WAL preparation"
10212                                        .into(),
10213                                ));
10214                            };
10215                            if let Some(before) = &delete_images[index] {
10216                                wal_records.push((
10217                                    table_id,
10218                                    Op::BeforeImage {
10219                                        table_id,
10220                                        row_id: *row_id,
10221                                        row: bincode::serialize(before).map_err(|error| {
10222                                            MongrelError::Other(format!(
10223                                                "before-image serialize: {error}"
10224                                            ))
10225                                        })?,
10226                                    },
10227                                ));
10228                            }
10229                            row_ids.push(*row_id);
10230                            index += 1;
10231                        }
10232                        wal_records.push((
10233                            table_id,
10234                            Op::Delete {
10235                                table_id,
10236                                row_ids: row_ids.clone(),
10237                            },
10238                        ));
10239                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
10240                    }
10241                    Staged::Truncate => {
10242                        wal_records.push((table_id, Op::TruncateTable { table_id }));
10243                        applies[batch_index].ops.push(StagedOp::Truncate);
10244                        index += 1;
10245                    }
10246                    Staged::Update { .. } => {
10247                        return Err(MongrelError::Other(
10248                            "transaction contains an unnormalized update at the sequencer".into(),
10249                        ));
10250                    }
10251                }
10252            }
10253
10254            for (name, state, _) in &prepared_external {
10255                wal_records.push((
10256                    EXTERNAL_TABLE_ID,
10257                    Op::ExternalTableState {
10258                        name: name.clone(),
10259                        state: state.clone(),
10260                    },
10261                ));
10262            }
10263
10264            for (table_id, definition) in &prepared_materialized_views {
10265                let mut definition = definition.clone();
10266                definition.last_refresh_epoch = new_epoch.0;
10267                wal_records.push((
10268                    *table_id,
10269                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
10270                        name: definition.name.clone(),
10271                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
10272                    }),
10273                ));
10274                committed_materialized_views.push(definition);
10275            }
10276            if !committed_materialized_views.is_empty() {
10277                let mut next_catalog = self.catalog.read().clone();
10278                for definition in &committed_materialized_views {
10279                    if let Some(existing) = next_catalog
10280                        .materialized_views
10281                        .iter_mut()
10282                        .find(|existing| existing.name == definition.name)
10283                    {
10284                        *existing = definition.clone();
10285                    } else {
10286                        next_catalog.materialized_views.push(definition.clone());
10287                    }
10288                }
10289                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10290                wal_records.push((
10291                    WAL_TABLE_ID,
10292                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
10293                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
10294                    }),
10295                ));
10296            }
10297
10298            if let Some(control) = control {
10299                if let Err(error) = control.checkpoint() {
10300                    drop(wal);
10301                    for (_, _, pending) in &prepared_external {
10302                        let _ = std::fs::remove_file(pending);
10303                    }
10304                    return Err(error);
10305                }
10306            }
10307            if let Some(before_commit) = before_commit.as_mut() {
10308                if let Err(error) = before_commit() {
10309                    drop(wal);
10310                    for (_, _, pending) in &prepared_external {
10311                        let _ = std::fs::remove_file(pending);
10312                    }
10313                    return Err(error);
10314                }
10315            }
10316
10317            let assigned_epoch = self.epoch.bump_assigned();
10318            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
10319            if assigned_epoch != new_epoch {
10320                for (_, _, pending) in &prepared_external {
10321                    let _ = std::fs::remove_file(pending);
10322                }
10323                return Err(MongrelError::Conflict(
10324                    "commit epoch changed while sequencer lock was held".into(),
10325                ));
10326            }
10327
10328            // From this point the outcome can become ambiguous. Keep prepared
10329            // spill files at the final names referenced by a possibly durable
10330            // commit marker; orphan cleanup is safe when the append did fail.
10331            prepared_run_links.disarm();
10332
10333            let append: Result<u64> = (|| {
10334                for (table_id, op) in wal_records {
10335                    wal.append(txn_id, table_id, op)?;
10336                }
10337                wal.append_commit(txn_id, new_epoch, &added_runs)
10338            })();
10339            let commit_seq =
10340                append.map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
10341
10342            // Record the conflict + assign the epoch under the WAL lock so commit
10343            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
10344            // moves out of this critical section to the group-commit coordinator
10345            // so concurrent committers share a single leader fsync.
10346            self.conflicts.record(&write_keys, new_epoch);
10347            (
10348                new_epoch,
10349                _epoch_guard,
10350                applies,
10351                committed_materialized_views,
10352                commit_seq,
10353            )
10354        };
10355        drop(commit_guard);
10356
10357        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
10358        self.await_durable_commit(commit_seq, new_epoch)?;
10359        drop(security_guard);
10360
10361        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
10362        let publish_result: Result<()> = {
10363            let mut first_error = None;
10364            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
10365            for run in &spilled {
10366                spilled_by_table.entry(run.table_id).or_default().push(run);
10367            }
10368            let mut modified_tables = Vec::with_capacity(applies.len());
10369            // Apply every table completely before any fallible manifest write.
10370            // The visible epoch remains unchanged until all tables are coherent.
10371            for batch in applies {
10372                #[cfg(test)]
10373                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10374                let mut t = batch.handle.lock();
10375                for op in batch.ops {
10376                    match op {
10377                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
10378                        StagedOp::Delete(row_ids) => {
10379                            for row_id in row_ids {
10380                                t.apply_delete(row_id, new_epoch);
10381                            }
10382                        }
10383                        StagedOp::Truncate => t.apply_truncate(new_epoch),
10384                    }
10385                }
10386                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
10387                    for run in runs {
10388                        t.link_run(crate::manifest::RunRef {
10389                            run_id: run.run_id,
10390                            level: 0,
10391                            epoch_created: new_epoch.0,
10392                            row_count: run.row_count,
10393                        });
10394                        t.apply_run_metadata_prepared(&run.rows)?;
10395                        if truncated_tables.contains(&batch.table_id) {
10396                            // TRUNCATE + spilled puts fully describe this table at
10397                            // the commit epoch. Endorse the epoch so clean-reopen
10398                            // recovery does not replay the truncate over the
10399                            // already-linked replacement run.
10400                            t.set_flushed_epoch(new_epoch);
10401                        }
10402                    }
10403                }
10404                t.invalidate_pending_cache();
10405                drop(t);
10406                modified_tables.push(batch.handle);
10407            }
10408
10409            // Checkpoint only after every live table carries the durable state.
10410            // Continue after one checkpoint failure so runtime publication stays
10411            // all-or-nothing; WAL recovery repairs failed files on reopen.
10412            for handle in modified_tables {
10413                #[cfg(test)]
10414                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
10415                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
10416                    first_error.get_or_insert(error);
10417                }
10418            }
10419            for (name, _, pending) in &prepared_external {
10420                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
10421                    first_error.get_or_insert(error);
10422                }
10423            }
10424            if !committed_materialized_views.is_empty() {
10425                let mut next_catalog = self.catalog.read().clone();
10426                for definition in committed_materialized_views {
10427                    if let Some(existing) = next_catalog
10428                        .materialized_views
10429                        .iter_mut()
10430                        .find(|existing| existing.name == definition.name)
10431                    {
10432                        *existing = definition;
10433                    } else {
10434                        next_catalog.materialized_views.push(definition);
10435                    }
10436                }
10437                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10438                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
10439                    first_error.get_or_insert(error);
10440                }
10441            }
10442            match first_error {
10443                Some(error) => Err(error),
10444                None => Ok(()),
10445            }
10446        };
10447
10448        if has_changes {
10449            let _ = self.change_wake.send(());
10450        }
10451        self.finish_durable_publish(new_epoch, &mut _epoch_guard, publish_result)?;
10452        Ok((new_epoch, committed_row_ids))
10453    }
10454
10455    /// Register a read snapshot at the current visible epoch and return it with
10456    /// a guard that retains it for GC until dropped.
10457    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
10458        let e = self.epoch.visible();
10459        let g = self.snapshots.register(e);
10460        (Snapshot::at(e), g)
10461    }
10462
10463    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
10464    /// retention.
10465    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
10466        let e = self.epoch.visible();
10467        let g = self.snapshots.register_owned(e);
10468        (Snapshot::at(e), g)
10469    }
10470
10471    /// Configure a rolling history window measured in prior commit epochs.
10472    /// The first enable starts at the current epoch because earlier versions
10473    /// may already have been compacted. Increasing the window likewise cannot
10474    /// recreate history that fell outside the previous guarantee.
10475    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
10476        let _guard = self.ddl_lock.lock();
10477        let current = self.epoch.visible();
10478        let (old_epochs, old_start) = self.snapshots.history_config();
10479        let earliest_already_guaranteed = if old_epochs == 0 {
10480            current
10481        } else {
10482            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
10483        };
10484        let start = if epochs == 0 {
10485            current
10486        } else {
10487            earliest_already_guaranteed
10488        };
10489        let published = std::cell::Cell::new(false);
10490        let result = write_history_retention(&self.root, epochs, start, || {
10491            self.snapshots.configure_history(epochs, start);
10492            published.set(true);
10493        });
10494        match result {
10495            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
10496                epoch: current.0,
10497                message: format!("history-retention publication was not durable: {error}"),
10498            }),
10499            result => result,
10500        }
10501    }
10502
10503    pub fn history_retention_epochs(&self) -> u64 {
10504        self.snapshots.history_config().0
10505    }
10506
10507    pub fn earliest_retained_epoch(&self) -> Epoch {
10508        let current = self.epoch.visible();
10509        self.snapshots.history_floor(current).unwrap_or(current)
10510    }
10511
10512    /// Pin a guaranteed historical epoch for the lifetime of the returned
10513    /// guard. Rejects future epochs and epochs outside the configured window.
10514    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
10515        let current = self.epoch.visible();
10516        if epoch > current {
10517            return Err(MongrelError::InvalidArgument(format!(
10518                "epoch {} is in the future; current epoch is {}",
10519                epoch.0, current.0
10520            )));
10521        }
10522        let earliest = self.earliest_retained_epoch();
10523        if epoch < earliest {
10524            return Err(MongrelError::InvalidArgument(format!(
10525                "epoch {} is no longer retained; earliest available epoch is {}",
10526                epoch.0, earliest.0
10527            )));
10528        }
10529        let guard = self.snapshots.register_owned(epoch);
10530        Ok((Snapshot::at(epoch), guard))
10531    }
10532
10533    /// Names of all live tables.
10534    pub fn table_names(&self) -> Vec<String> {
10535        self.catalog
10536            .read()
10537            .tables
10538            .iter()
10539            .filter(|t| matches!(t.state, TableState::Live))
10540            .map(|t| t.name.clone())
10541            .collect()
10542    }
10543
10544    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
10545    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
10546    /// reaped on the next open. Call this as the last action before a
10547    /// short-lived process (CLI, one-shot script) exits. The daemon does not
10548    /// need this — its background auto-compactor handles run management.
10549    pub fn close(&self) -> Result<()> {
10550        for name in self.table_names() {
10551            if let Ok(handle) = self.table(&name) {
10552                if let Err(e) = handle.lock().close() {
10553                    eprintln!("[close] flush failed for {name}: {e}");
10554                }
10555            }
10556        }
10557        Ok(())
10558    }
10559
10560    /// Compact every mounted table: merge all sorted runs into one clean run
10561    /// so query cost stays flat (single-run fast path) instead of growing
10562    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
10563    /// rows to reclaim. Each table
10564    /// is locked individually for its own compaction; snapshot retention is
10565    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
10566    pub fn compact(&self) -> Result<(usize, usize)> {
10567        self.require(&crate::auth::Permission::Ddl)?;
10568        let mut compacted = 0;
10569        let mut skipped = 0;
10570        for name in self.table_names() {
10571            let Ok(handle) = self.table(&name) else {
10572                continue;
10573            };
10574            {
10575                let mut t = handle.lock();
10576                let before = t.run_count();
10577                if before < 2 && !t.should_compact() {
10578                    skipped += 1;
10579                    continue;
10580                }
10581                match t.compact() {
10582                    Ok(()) => {
10583                        let after = t.run_count();
10584                        compacted += 1;
10585                        eprintln!("[compact] {name}: {before} -> {after} runs");
10586                    }
10587                    Err(e) => {
10588                        eprintln!("[compact] {name}: compaction failed: {e}");
10589                        skipped += 1;
10590                    }
10591                }
10592            }
10593        }
10594        Ok((compacted, skipped))
10595    }
10596
10597    /// Compact a single table by name. Returns `Ok(true)` if it was
10598    /// compacted, `Ok(false)` if skipped (< 2 runs).
10599    pub fn compact_table(&self, name: &str) -> Result<bool> {
10600        self.require(&crate::auth::Permission::Ddl)?;
10601        let handle = self.table(name)?;
10602        let mut t = handle.lock();
10603        let before = t.run_count();
10604        if before < 2 {
10605            return Ok(false);
10606        }
10607        t.compact()?;
10608        Ok(t.run_count() < before)
10609    }
10610
10611    /// Look up a live table by name.
10612    pub fn table(&self, name: &str) -> Result<TableHandle> {
10613        self.ensure_owner_process()?;
10614        let cat = self.catalog.read();
10615        let entry = cat
10616            .live(name)
10617            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10618        let id = entry.table_id;
10619        drop(cat);
10620        self.tables
10621            .read()
10622            .get(&id)
10623            .cloned()
10624            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
10625    }
10626
10627    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
10628    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
10629    pub fn has_ttl_tables(&self) -> bool {
10630        self.tables
10631            .read()
10632            .values()
10633            .any(|table| table.lock().ttl().is_some())
10634    }
10635
10636    /// Resolve a live table id → mounted handle (used by the constraint
10637    /// validation pass and other id-qualified internal paths).
10638    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
10639        self.tables
10640            .read()
10641            .get(&id)
10642            .cloned()
10643            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
10644    }
10645
10646    /// Create a new table. The DDL is first logged to the shared WAL
10647    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
10648    /// BEFORE the in-memory catalog and table map are mutated; the catalog
10649    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
10650    /// that sees a stale catalog still recovers the table by replaying the Ddl.
10651    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
10652        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10653            return Err(MongrelError::InvalidArgument(format!(
10654                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
10655            )));
10656        }
10657        self.create_table_with_state(name, schema, TableState::Live)
10658    }
10659
10660    /// Create a durable but non-queryable CTAS build table.
10661    #[doc(hidden)]
10662    pub fn create_building_table(
10663        &self,
10664        build_name: &str,
10665        intended_name: &str,
10666        query_id: &str,
10667        schema: Schema,
10668    ) -> Result<u64> {
10669        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10670            || intended_name.is_empty()
10671            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10672            || query_id.is_empty()
10673        {
10674            return Err(MongrelError::InvalidArgument(
10675                "invalid CTAS building-table identity".into(),
10676            ));
10677        }
10678        self.create_table_with_state(
10679            build_name,
10680            schema,
10681            TableState::Building {
10682                intended_name: intended_name.to_string(),
10683                query_id: query_id.to_string(),
10684                created_at_unix_nanos: current_unix_nanos(),
10685                replaces_table_id: None,
10686            },
10687        )
10688    }
10689
10690    /// Create a hidden schema-rebuild table while the intended target remains
10691    /// live. Publication later validates that the same target is still live.
10692    #[doc(hidden)]
10693    pub fn create_rebuilding_table(
10694        &self,
10695        build_name: &str,
10696        intended_name: &str,
10697        query_id: &str,
10698        schema: Schema,
10699    ) -> Result<u64> {
10700        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10701            || intended_name.is_empty()
10702            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10703            || query_id.is_empty()
10704        {
10705            return Err(MongrelError::InvalidArgument(
10706                "invalid rebuilding-table identity".into(),
10707            ));
10708        }
10709        let replaces_table_id = self
10710            .catalog
10711            .read()
10712            .live(intended_name)
10713            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
10714            .table_id;
10715        self.create_table_with_state(
10716            build_name,
10717            schema,
10718            TableState::Building {
10719                intended_name: intended_name.to_string(),
10720                query_id: query_id.to_string(),
10721                created_at_unix_nanos: current_unix_nanos(),
10722                replaces_table_id: Some(replaces_table_id),
10723            },
10724        )
10725    }
10726
10727    fn create_table_with_state(
10728        &self,
10729        name: &str,
10730        schema: Schema,
10731        state: TableState,
10732    ) -> Result<u64> {
10733        use crate::wal::DdlOp;
10734        use std::sync::atomic::Ordering;
10735
10736        self.require(&crate::auth::Permission::Ddl)?;
10737        if self.poisoned.load(Ordering::Relaxed) {
10738            return Err(MongrelError::Other(
10739                "database poisoned by fsync error".into(),
10740            ));
10741        }
10742
10743        let _g = self.ddl_lock.lock();
10744        let _security_write = self.security_write()?;
10745        self.require(&crate::auth::Permission::Ddl)?;
10746        {
10747            let cat = self.catalog.read();
10748            match &state {
10749                TableState::Live => {
10750                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
10751                        return Err(MongrelError::InvalidArgument(format!(
10752                            "table {name:?} already exists or is being built"
10753                        )));
10754                    }
10755                }
10756                TableState::Building {
10757                    intended_name,
10758                    replaces_table_id,
10759                    ..
10760                } => {
10761                    let target_matches = match replaces_table_id {
10762                        Some(table_id) => cat
10763                            .live(intended_name)
10764                            .is_some_and(|entry| entry.table_id == *table_id),
10765                        None => cat.live(intended_name).is_none(),
10766                    };
10767                    if !target_matches || cat.building_for(intended_name).is_some() {
10768                        return Err(MongrelError::InvalidArgument(format!(
10769                            "table {intended_name:?} changed or is already being built"
10770                        )));
10771                    }
10772                    if cat.building(name).is_some() {
10773                        return Err(MongrelError::InvalidArgument(format!(
10774                            "building table {name:?} already exists"
10775                        )));
10776                    }
10777                }
10778                TableState::Dropped { .. } => {
10779                    return Err(MongrelError::InvalidArgument(
10780                        "cannot create a dropped table".into(),
10781                    ));
10782                }
10783            }
10784        }
10785
10786        // Allocate id + epoch + txn id under the commit lock so the DDL commit
10787        // is serialized with data commits (in-order publish).
10788        let commit_lock = Arc::clone(&self.commit_lock);
10789        let _c = commit_lock.lock();
10790        let table_id = {
10791            let mut cat = self.catalog.write();
10792            let id = cat.next_table_id;
10793            cat.next_table_id = id
10794                .checked_add(1)
10795                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
10796            Result::<u64>::Ok(id)
10797        }?;
10798        let epoch = self.epoch.bump_assigned();
10799        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10800        let txn_id = self.alloc_txn_id()?;
10801
10802        // Stamp the schema_id with the unique table_id so every table in the
10803        // database has a distinct schema_id (caller-provided values are
10804        // ignored to prevent collisions).
10805        let mut schema = schema;
10806        schema.schema_id = table_id;
10807        // Defense in depth: reject an invalid schema BEFORE any durable
10808        // side-effect. `Table::create_in` re-validates, but by then the DDL has
10809        // already been appended to the shared WAL; a failing create_in would
10810        // leave a dangling entry that `recover_ddl_from_wal` replays without
10811        // re-validating, corrupting the catalog on reopen. Validating here
10812        // keeps the WAL free of schemas that can never be opened.
10813        schema.validate_auto_increment()?;
10814        schema.validate_defaults()?;
10815        schema.validate_ai()?;
10816        for index in &schema.indexes {
10817            index.validate_options()?;
10818        }
10819        for constraint in &schema.constraints.checks {
10820            constraint.expr.validate()?;
10821        }
10822
10823        // Build the complete mounted table before its DDL can become durable.
10824        // Any failure removes the unpublished directory and abandons the epoch.
10825        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
10826        let canonical_tdir = self.root.join(&table_relative);
10827        let table_root = Arc::new(
10828            self.durable_root
10829                .create_directory_all_pinned(&table_relative)?,
10830        );
10831        let tdir = table_root.io_path()?;
10832        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
10833        let ctx = SharedCtx {
10834            root_guard: Some(table_root),
10835            epoch: Arc::clone(&self.epoch),
10836            page_cache: Arc::clone(&self.page_cache),
10837            decoded_cache: Arc::clone(&self.decoded_cache),
10838            snapshots: Arc::clone(&self.snapshots),
10839            kek: self.kek.clone(),
10840            commit_lock: Arc::clone(&self.commit_lock),
10841            shared: Some(crate::engine::SharedWalCtx {
10842                wal: Arc::clone(&self.shared_wal),
10843                group: Arc::clone(&self.group),
10844                poisoned: Arc::clone(&self.poisoned),
10845                txn_ids: Arc::clone(&self.next_txn_id),
10846                change_wake: self.change_wake.clone(),
10847            }),
10848            table_name: Some(name.to_string()),
10849            auth: self.table_auth_checker(),
10850            read_only: self.read_only,
10851        };
10852        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
10853
10854        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
10855        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
10856        let schema_json = DdlOp::encode_schema(&schema)?;
10857        let ddl = match &state {
10858            TableState::Live => DdlOp::CreateTable {
10859                table_id,
10860                name: name.to_string(),
10861                schema_json,
10862            },
10863            TableState::Building {
10864                intended_name,
10865                query_id,
10866                created_at_unix_nanos,
10867                replaces_table_id,
10868            } => match replaces_table_id {
10869                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
10870                    table_id,
10871                    build_name: name.to_string(),
10872                    intended_name: intended_name.clone(),
10873                    query_id: query_id.clone(),
10874                    created_at_unix_nanos: *created_at_unix_nanos,
10875                    replaces_table_id: *replaces_table_id,
10876                    schema_json,
10877                },
10878                None => DdlOp::CreateBuildingTable {
10879                    table_id,
10880                    build_name: name.to_string(),
10881                    intended_name: intended_name.clone(),
10882                    query_id: query_id.clone(),
10883                    created_at_unix_nanos: *created_at_unix_nanos,
10884                    schema_json,
10885                },
10886            },
10887            TableState::Dropped { .. } => {
10888                return Err(MongrelError::InvalidArgument(
10889                    "cannot create a table in dropped state".into(),
10890                ));
10891            }
10892        };
10893        let mut next_catalog = self.catalog.read().clone();
10894        next_catalog.tables.push(CatalogEntry {
10895            table_id,
10896            name: name.to_string(),
10897            schema: schema.clone(),
10898            state: state.clone(),
10899            created_epoch: epoch.0,
10900        });
10901        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10902        let commit_seq = {
10903            let mut wal = self.shared_wal.lock();
10904            let append: Result<u64> = (|| {
10905                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
10906                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10907                wal.append_commit(txn_id, epoch, &[])
10908            })();
10909            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10910        };
10911        self.await_durable_commit(commit_seq, epoch)?;
10912        pending_table_dir.disarm();
10913
10914        // Publish the mounted table and catalog in memory even if the catalog
10915        // checkpoint fails after the WAL commit.
10916        self.tables
10917            .write()
10918            .insert(table_id, TableHandle::new(table));
10919        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10920        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10921        Ok(table_id)
10922    }
10923
10924    /// Logically drop a table, logging the DDL through the shared WAL first.
10925    pub fn drop_table(&self, name: &str) -> Result<()> {
10926        self.drop_table_with_epoch(name).map(|_| ())
10927    }
10928
10929    /// Logically drop a table and return the exact publication epoch.
10930    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
10931        self.drop_table_with_state(name, false, None)
10932    }
10933
10934    pub fn drop_table_with_epoch_controlled<F>(
10935        &self,
10936        name: &str,
10937        mut before_commit: F,
10938    ) -> Result<Epoch>
10939    where
10940        F: FnMut() -> Result<()>,
10941    {
10942        self.drop_table_with_state(name, false, Some(&mut before_commit))
10943    }
10944
10945    /// Discard an unpublished CTAS build.
10946    #[doc(hidden)]
10947    pub fn discard_building_table(&self, name: &str) -> Result<()> {
10948        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10949            return Err(MongrelError::InvalidArgument(
10950                "not a CTAS building table".into(),
10951            ));
10952        }
10953        self.drop_table_with_state(name, true, None).map(|_| ())
10954    }
10955
10956    fn drop_table_with_state(
10957        &self,
10958        name: &str,
10959        building: bool,
10960        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10961    ) -> Result<Epoch> {
10962        use crate::wal::DdlOp;
10963        use std::sync::atomic::Ordering;
10964
10965        self.require(&crate::auth::Permission::Ddl)?;
10966        if self.poisoned.load(Ordering::Relaxed) {
10967            return Err(MongrelError::Other(
10968                "database poisoned by fsync error".into(),
10969            ));
10970        }
10971
10972        let _g = self.ddl_lock.lock();
10973        let _security_write = self.security_write()?;
10974        self.require(&crate::auth::Permission::Ddl)?;
10975        let table_id = {
10976            let cat = self.catalog.read();
10977            if building {
10978                cat.building(name)
10979            } else {
10980                cat.live(name)
10981            }
10982            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
10983            .table_id
10984        };
10985
10986        let commit_lock = Arc::clone(&self.commit_lock);
10987        let _c = commit_lock.lock();
10988        let epoch = self.epoch.bump_assigned();
10989        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10990        let txn_id = self.alloc_txn_id()?;
10991        let mut next_catalog = self.catalog.read().clone();
10992        let entry = next_catalog
10993            .tables
10994            .iter_mut()
10995            .find(|t| t.table_id == table_id)
10996            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10997        entry.state = TableState::Dropped { at_epoch: epoch.0 };
10998        next_catalog.triggers.retain(|trigger| {
10999            !matches!(
11000                &trigger.trigger.target,
11001                TriggerTarget::Table(target) if target == name
11002            )
11003        });
11004        next_catalog
11005            .materialized_views
11006            .retain(|definition| definition.name != name);
11007        next_catalog
11008            .security
11009            .rls_tables
11010            .retain(|table| table != name);
11011        next_catalog
11012            .security
11013            .policies
11014            .retain(|policy| policy.table != name);
11015        next_catalog
11016            .security
11017            .masks
11018            .retain(|mask| mask.table != name);
11019        for role in &mut next_catalog.roles {
11020            role.permissions
11021                .retain(|permission| permission_table(permission) != Some(name));
11022        }
11023        advance_security_version(&mut next_catalog)?;
11024        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11025        let commit_seq = {
11026            let mut wal = self.shared_wal.lock();
11027            if let Some(before_commit) = before_commit {
11028                before_commit()?;
11029            }
11030            let append: Result<u64> = (|| {
11031                wal.append(
11032                    txn_id,
11033                    table_id,
11034                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
11035                )?;
11036                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11037                wal.append_commit(txn_id, epoch, &[])
11038            })();
11039            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11040        };
11041        self.await_durable_commit(commit_seq, epoch)?;
11042
11043        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11044        self.tables.write().remove(&table_id);
11045        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
11046        Ok(epoch)
11047    }
11048
11049    /// Rename a live table. `name` must exist and `new_name` must not collide
11050    /// with any live table; both checks run under `ddl_lock` so they are atomic
11051    /// with the rename and with concurrent `create_table` existence checks (no
11052    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
11053    /// side-effects. The rename is logged to the shared WAL as
11054    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
11055    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
11056    /// the in-memory object does not move — only the catalog name changes).
11057    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
11058        self.rename_table_with_epoch(name, new_name).map(|_| ())
11059    }
11060
11061    /// Rename a table and return its exact publication epoch.
11062    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
11063        self.rename_table_with_epoch_inner(name, new_name, None)
11064    }
11065
11066    pub fn rename_table_with_epoch_controlled<F>(
11067        &self,
11068        name: &str,
11069        new_name: &str,
11070        mut before_commit: F,
11071    ) -> Result<Epoch>
11072    where
11073        F: FnMut() -> Result<()>,
11074    {
11075        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
11076    }
11077
11078    fn rename_table_with_epoch_inner(
11079        &self,
11080        name: &str,
11081        new_name: &str,
11082        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11083    ) -> Result<Epoch> {
11084        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11085            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11086        {
11087            return Err(MongrelError::InvalidArgument(
11088                "the CTAS building-table namespace is reserved".into(),
11089            ));
11090        }
11091        self.rename_table_with_state(name, new_name, false, None, before_commit)
11092    }
11093
11094    /// Atomically publish a hidden CTAS build under its intended live name.
11095    #[doc(hidden)]
11096    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
11097        self.publish_building_table_inner(build_name, new_name, None)
11098    }
11099
11100    #[doc(hidden)]
11101    pub fn publish_building_table_controlled<F>(
11102        &self,
11103        build_name: &str,
11104        new_name: &str,
11105        mut before_commit: F,
11106    ) -> Result<Epoch>
11107    where
11108        F: FnMut() -> Result<()>,
11109    {
11110        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
11111    }
11112
11113    fn publish_building_table_inner(
11114        &self,
11115        build_name: &str,
11116        new_name: &str,
11117        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11118    ) -> Result<Epoch> {
11119        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11120            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11121        {
11122            return Err(MongrelError::InvalidArgument(
11123                "invalid CTAS publish identity".into(),
11124            ));
11125        }
11126        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
11127    }
11128
11129    /// Atomically publish a hidden build and its materialized-view definition.
11130    #[doc(hidden)]
11131    pub fn publish_materialized_building_table(
11132        &self,
11133        build_name: &str,
11134        new_name: &str,
11135        definition: crate::catalog::MaterializedViewEntry,
11136    ) -> Result<Epoch> {
11137        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
11138    }
11139
11140    #[doc(hidden)]
11141    pub fn publish_materialized_building_table_controlled<F>(
11142        &self,
11143        build_name: &str,
11144        new_name: &str,
11145        definition: crate::catalog::MaterializedViewEntry,
11146        mut before_commit: F,
11147    ) -> Result<Epoch>
11148    where
11149        F: FnMut() -> Result<()>,
11150    {
11151        self.publish_materialized_building_table_inner(
11152            build_name,
11153            new_name,
11154            definition,
11155            Some(&mut before_commit),
11156        )
11157    }
11158
11159    fn publish_materialized_building_table_inner(
11160        &self,
11161        build_name: &str,
11162        new_name: &str,
11163        definition: crate::catalog::MaterializedViewEntry,
11164        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11165    ) -> Result<Epoch> {
11166        if definition.name != new_name || definition.query.trim().is_empty() {
11167            return Err(MongrelError::InvalidArgument(
11168                "invalid materialized-view publication".into(),
11169            ));
11170        }
11171        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
11172    }
11173
11174    /// Atomically replace a still-live table with its completed hidden rebuild.
11175    #[doc(hidden)]
11176    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
11177        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
11178    }
11179
11180    #[doc(hidden)]
11181    pub fn publish_rebuilding_table_controlled<F>(
11182        &self,
11183        build_name: &str,
11184        new_name: &str,
11185        mut before_commit: F,
11186    ) -> Result<Epoch>
11187    where
11188        F: FnMut() -> Result<()>,
11189    {
11190        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
11191    }
11192
11193    /// Atomically replace a live materialized-view table and its definition.
11194    #[doc(hidden)]
11195    pub fn publish_materialized_rebuilding_table_controlled<F>(
11196        &self,
11197        build_name: &str,
11198        new_name: &str,
11199        definition: crate::catalog::MaterializedViewEntry,
11200        mut before_commit: F,
11201    ) -> Result<Epoch>
11202    where
11203        F: FnMut() -> Result<()>,
11204    {
11205        self.publish_rebuilding_table_inner(
11206            build_name,
11207            new_name,
11208            Some(definition),
11209            Some(&mut before_commit),
11210        )
11211    }
11212
11213    fn publish_rebuilding_table_inner(
11214        &self,
11215        build_name: &str,
11216        new_name: &str,
11217        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11218        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11219    ) -> Result<Epoch> {
11220        use crate::wal::DdlOp;
11221
11222        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11223            || new_name.is_empty()
11224            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11225        {
11226            return Err(MongrelError::InvalidArgument(
11227                "invalid rebuilding-table publish identity".into(),
11228            ));
11229        }
11230        if materialized_view.as_ref().is_some_and(|definition| {
11231            definition.name != new_name || definition.query.trim().is_empty()
11232        }) {
11233            return Err(MongrelError::InvalidArgument(
11234                "invalid materialized-view replacement".into(),
11235            ));
11236        }
11237        self.require(&crate::auth::Permission::Ddl)?;
11238        if self.poisoned.load(Ordering::Relaxed) {
11239            return Err(MongrelError::Other(
11240                "database poisoned by fsync error".into(),
11241            ));
11242        }
11243
11244        let _ddl = self.ddl_lock.lock();
11245        let _security_write = self.security_write()?;
11246        let (table_id, replaced_table_id) = {
11247            let catalog = self.catalog.read();
11248            let build = catalog.building(build_name).ok_or_else(|| {
11249                MongrelError::NotFound(format!("building table {build_name:?} not found"))
11250            })?;
11251            let replaced_table_id = match &build.state {
11252                TableState::Building {
11253                    intended_name,
11254                    replaces_table_id: Some(replaced_table_id),
11255                    ..
11256                } if intended_name == new_name => *replaced_table_id,
11257                _ => {
11258                    return Err(MongrelError::InvalidArgument(format!(
11259                        "building table {build_name:?} is not a replacement for {new_name:?}"
11260                    )))
11261                }
11262            };
11263            if catalog
11264                .live(new_name)
11265                .is_none_or(|entry| entry.table_id != replaced_table_id)
11266            {
11267                return Err(MongrelError::Conflict(format!(
11268                    "table {new_name:?} changed while its replacement was built"
11269                )));
11270            }
11271            (build.table_id, replaced_table_id)
11272        };
11273
11274        let _commit = self.commit_lock.lock();
11275        let epoch = self.epoch.assigned().next();
11276        let txn_id = self.alloc_txn_id()?;
11277        let mut next_catalog = self.catalog.read().clone();
11278        apply_rebuilding_publish(
11279            &mut next_catalog,
11280            table_id,
11281            replaced_table_id,
11282            new_name,
11283            epoch.0,
11284        )?;
11285        if let Some(definition) = materialized_view.as_mut() {
11286            definition.last_refresh_epoch = epoch.0;
11287        }
11288        let materialized_view_json = materialized_view
11289            .as_ref()
11290            .map(DdlOp::encode_materialized_view)
11291            .transpose()?;
11292        if let Some(definition) = materialized_view {
11293            if let Some(existing) = next_catalog
11294                .materialized_views
11295                .iter_mut()
11296                .find(|existing| existing.name == definition.name)
11297            {
11298                *existing = definition;
11299            } else {
11300                next_catalog.materialized_views.push(definition);
11301            }
11302        }
11303        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11304        if let Some(before_commit) = before_commit {
11305            before_commit()?;
11306        }
11307        let assigned_epoch = self.epoch.bump_assigned();
11308        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
11309        if assigned_epoch != epoch {
11310            return Err(MongrelError::Conflict(
11311                "commit epoch changed while sequencer lock was held".into(),
11312            ));
11313        }
11314        let commit_seq = {
11315            let mut wal = self.shared_wal.lock();
11316            let append: Result<u64> = (|| {
11317                wal.append(
11318                    txn_id,
11319                    table_id,
11320                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
11321                        table_id,
11322                        replaced_table_id,
11323                        new_name: new_name.to_string(),
11324                    }),
11325                )?;
11326                if let Some(definition_json) = materialized_view_json {
11327                    wal.append(
11328                        txn_id,
11329                        table_id,
11330                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11331                            name: new_name.to_string(),
11332                            definition_json,
11333                        }),
11334                    )?;
11335                }
11336                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11337                wal.append_commit(txn_id, epoch, &[])
11338            })();
11339            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11340        };
11341        self.await_durable_commit(commit_seq, epoch)?;
11342
11343        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11344        self.tables.write().remove(&replaced_table_id);
11345        if let Some(table) = self.tables.read().get(&table_id) {
11346            table.lock().set_catalog_name(new_name.to_string());
11347        }
11348        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
11349        Ok(epoch)
11350    }
11351
11352    fn rename_table_with_state(
11353        &self,
11354        name: &str,
11355        new_name: &str,
11356        building: bool,
11357        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11358        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11359    ) -> Result<Epoch> {
11360        use crate::wal::DdlOp;
11361        use std::sync::atomic::Ordering;
11362
11363        self.require(&crate::auth::Permission::Ddl)?;
11364        if self.poisoned.load(Ordering::Relaxed) {
11365            return Err(MongrelError::Other(
11366                "database poisoned by fsync error".into(),
11367            ));
11368        }
11369
11370        // A no-op rename short-circuits before any locking, so it can never
11371        // trip the "target already exists" check (the source *is* that name).
11372        if name == new_name {
11373            return Ok(self.visible_epoch());
11374        }
11375        if new_name.is_empty() {
11376            return Err(MongrelError::InvalidArgument(
11377                "rename_table: new name must not be empty".into(),
11378            ));
11379        }
11380
11381        let _g = self.ddl_lock.lock();
11382        let _security_write = self.security_write()?;
11383        self.require(&crate::auth::Permission::Ddl)?;
11384        let table_id = {
11385            let cat = self.catalog.read();
11386            let src = if building {
11387                cat.building(name)
11388            } else {
11389                cat.live(name)
11390            }
11391            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11392            if building
11393                && !matches!(
11394                    &src.state,
11395                    TableState::Building { intended_name, .. } if intended_name == new_name
11396                )
11397            {
11398                return Err(MongrelError::InvalidArgument(format!(
11399                    "building table {name:?} is not reserved for {new_name:?}"
11400                )));
11401            }
11402            // Target must be free. Checked under ddl_lock, which every other
11403            // DDL (create/rename/drop) also holds, so a concurrent operation
11404            // cannot claim `new_name` between this check and the catalog write.
11405            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
11406                return Err(MongrelError::InvalidArgument(format!(
11407                    "rename_table: a table named {new_name:?} already exists"
11408                )));
11409            }
11410            src.table_id
11411        };
11412
11413        let commit_lock = Arc::clone(&self.commit_lock);
11414        let _c = commit_lock.lock();
11415        let epoch = self.epoch.bump_assigned();
11416        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11417        let txn_id = self.alloc_txn_id()?;
11418        if let Some(definition) = materialized_view.as_mut() {
11419            definition.last_refresh_epoch = epoch.0;
11420        }
11421        let materialized_view_json = materialized_view
11422            .as_ref()
11423            .map(DdlOp::encode_materialized_view)
11424            .transpose()?;
11425        let mut next_catalog = self.catalog.read().clone();
11426        let entry = next_catalog
11427            .tables
11428            .iter_mut()
11429            .find(|t| t.table_id == table_id)
11430            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11431        entry.name = new_name.to_string();
11432        if building {
11433            entry.state = TableState::Live;
11434        }
11435        for trigger in &mut next_catalog.triggers {
11436            if matches!(
11437                &trigger.trigger.target,
11438                TriggerTarget::Table(target) if target == name
11439            ) {
11440                trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
11441            }
11442        }
11443        if let Some(definition) = next_catalog
11444            .materialized_views
11445            .iter_mut()
11446            .find(|definition| definition.name == name)
11447        {
11448            definition.name = new_name.to_string();
11449        }
11450        if let Some(definition) = materialized_view.take() {
11451            next_catalog.materialized_views.push(definition);
11452        }
11453        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11454        for table in &mut next_catalog.security.rls_tables {
11455            if table == name {
11456                *table = new_name.to_string();
11457            }
11458        }
11459        for policy in &mut next_catalog.security.policies {
11460            if policy.table == name {
11461                policy.table = new_name.to_string();
11462            }
11463        }
11464        for mask in &mut next_catalog.security.masks {
11465            if mask.table == name {
11466                mask.table = new_name.to_string();
11467            }
11468        }
11469        for role in &mut next_catalog.roles {
11470            for permission in &mut role.permissions {
11471                rename_permission_table(permission, name, new_name);
11472            }
11473        }
11474        advance_security_version(&mut next_catalog)?;
11475        let ddl = if building {
11476            DdlOp::PublishBuildingTable {
11477                table_id,
11478                new_name: new_name.to_string(),
11479            }
11480        } else {
11481            DdlOp::RenameTable {
11482                table_id,
11483                new_name: new_name.to_string(),
11484            }
11485        };
11486        let commit_seq = {
11487            let mut wal = self.shared_wal.lock();
11488            if let Some(before_commit) = before_commit {
11489                before_commit()?;
11490            }
11491            let append: Result<u64> = (|| {
11492                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
11493                if let Some(definition_json) = materialized_view_json {
11494                    wal.append(
11495                        txn_id,
11496                        table_id,
11497                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11498                            name: new_name.to_string(),
11499                            definition_json,
11500                        }),
11501                    )?;
11502                }
11503                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11504                wal.append_commit(txn_id, epoch, &[])
11505            })();
11506            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11507        };
11508        self.await_durable_commit(commit_seq, epoch)?;
11509
11510        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11511        // The in-memory table object is keyed by table_id, not name, so it does
11512        // not move and live TableHandles remain valid.
11513        if let Some(table) = self.tables.read().get(&table_id) {
11514            table.lock().set_catalog_name(new_name.to_string());
11515        }
11516        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
11517        Ok(epoch)
11518    }
11519
11520    pub fn alter_column(
11521        &self,
11522        table_name: &str,
11523        column_name: &str,
11524        change: AlterColumn,
11525    ) -> Result<ColumnDef> {
11526        self.alter_column_with_epoch(table_name, column_name, change)
11527            .map(|(column, _)| column)
11528    }
11529
11530    pub fn alter_column_with_epoch(
11531        &self,
11532        table_name: &str,
11533        column_name: &str,
11534        change: AlterColumn,
11535    ) -> Result<(ColumnDef, Option<Epoch>)> {
11536        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
11537    }
11538
11539    /// Cooperatively prepare an ALTER and fence each durable commit separately.
11540    /// `after_commit(Some(epoch))` follows an exact durable outcome;
11541    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
11542    /// for every successful `before_commit` callback.
11543    pub fn alter_column_with_epoch_controlled<B, A>(
11544        &self,
11545        table_name: &str,
11546        column_name: &str,
11547        change: AlterColumn,
11548        control: &crate::ExecutionControl,
11549        mut before_commit: B,
11550        mut after_commit: A,
11551    ) -> Result<(ColumnDef, Option<Epoch>)>
11552    where
11553        B: FnMut() -> Result<()>,
11554        A: FnMut(Option<Epoch>) -> Result<()>,
11555    {
11556        self.alter_column_with_epoch_inner(
11557            table_name,
11558            column_name,
11559            change,
11560            Some(control),
11561            Some(&mut before_commit),
11562            Some(&mut after_commit),
11563        )
11564    }
11565
11566    #[allow(clippy::too_many_arguments)]
11567    fn alter_column_with_epoch_inner(
11568        &self,
11569        table_name: &str,
11570        column_name: &str,
11571        change: AlterColumn,
11572        control: Option<&crate::ExecutionControl>,
11573        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11574        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
11575    ) -> Result<(ColumnDef, Option<Epoch>)> {
11576        use crate::wal::DdlOp;
11577        use std::sync::atomic::Ordering;
11578
11579        self.require(&crate::auth::Permission::Ddl)?;
11580        commit_prepare_checkpoint(control, 0)?;
11581        if self.poisoned.load(Ordering::Relaxed) {
11582            return Err(MongrelError::Other(
11583                "database poisoned by fsync error".into(),
11584            ));
11585        }
11586
11587        let _g = self.ddl_lock.lock();
11588        let table_id = {
11589            let cat = self.catalog.read();
11590            cat.live(table_name)
11591                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11592                .table_id
11593        };
11594        let handle =
11595            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11596                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11597            })?;
11598
11599        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
11600        // declared default, backfill existing NULL/absent cells as one durable
11601        // transaction before logging the metadata change. A crash between the
11602        // two commits leaves a harmless nullable-but-filled column; retry is
11603        // idempotent because only remaining NULLs are touched.
11604        let backfill = {
11605            let table = handle.lock();
11606            let old = table
11607                .schema()
11608                .column(column_name)
11609                .cloned()
11610                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
11611            let next_flags = change.flags.unwrap_or(old.flags);
11612            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
11613                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
11614                && old.default_value.is_some()
11615            {
11616                let snapshot = Snapshot::at(self.epoch.visible());
11617                let mut updates = Vec::new();
11618                let rows = match control {
11619                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
11620                    None => table.visible_rows(snapshot)?,
11621                };
11622                for (row_index, row) in rows.into_iter().enumerate() {
11623                    commit_prepare_checkpoint(control, row_index)?;
11624                    if row
11625                        .columns
11626                        .get(&old.id)
11627                        .is_some_and(|value| !matches!(value, Value::Null))
11628                    {
11629                        continue;
11630                    }
11631                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
11632                    table.apply_defaults(&mut cells)?;
11633                    updates.push((
11634                        table_id,
11635                        crate::txn::Staged::Update {
11636                            row_id: row.row_id,
11637                            new_row: cells,
11638                            changed_columns: vec![old.id],
11639                        },
11640                    ));
11641                }
11642                updates
11643            } else {
11644                Vec::new()
11645            }
11646        };
11647        let durable_epoch = std::cell::Cell::new(None);
11648        let backfill_epoch = if backfill.is_empty() {
11649            None
11650        } else {
11651            let (principal, catalog_bound) = self.transaction_principal_snapshot();
11652            let txn_id = self.alloc_txn_id()?;
11653            let mut entered_fence = false;
11654            let commit_result = match (control, before_commit.as_deref_mut()) {
11655                (Some(control), Some(before_commit)) => self
11656                    .commit_transaction_with_external_states_controlled(
11657                        txn_id,
11658                        self.epoch.visible(),
11659                        backfill,
11660                        Vec::new(),
11661                        Vec::new(),
11662                        principal.clone(),
11663                        catalog_bound,
11664                        None,
11665                        control,
11666                        &mut || {
11667                            before_commit()?;
11668                            entered_fence = true;
11669                            Ok(())
11670                        },
11671                    )
11672                    .map(|(epoch, _)| epoch),
11673                _ => self
11674                    .commit_transaction_with_external_states(
11675                        txn_id,
11676                        self.epoch.visible(),
11677                        backfill,
11678                        Vec::new(),
11679                        Vec::new(),
11680                        principal,
11681                        catalog_bound,
11682                        None,
11683                    )
11684                    .map(|(epoch, _)| epoch),
11685            };
11686            let commit_result = if entered_fence {
11687                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11688            } else {
11689                commit_result
11690            };
11691            match &commit_result {
11692                Ok(epoch) => durable_epoch.set(Some(*epoch)),
11693                Err(MongrelError::DurableCommit { epoch, .. }) => {
11694                    durable_epoch.set(Some(Epoch(*epoch)));
11695                }
11696                Err(_) => {}
11697            }
11698            Some(commit_result?)
11699        };
11700        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
11701            let _security_write = self.security_write()?;
11702            self.require(&crate::auth::Permission::Ddl)?;
11703            if self
11704                .catalog
11705                .read()
11706                .live(table_name)
11707                .is_none_or(|entry| entry.table_id != table_id)
11708            {
11709                return Err(MongrelError::Conflict(format!(
11710                    "table {table_name:?} changed during ALTER"
11711                )));
11712            }
11713            let mut table = handle.lock();
11714            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
11715            let renamed_column = (column.name != column_name).then(|| column.name.clone());
11716            let Some(prepared_schema) = prepared_schema else {
11717                return Ok((column, backfill_epoch));
11718            };
11719
11720            let commit_lock = Arc::clone(&self.commit_lock);
11721            let _c = commit_lock.lock();
11722            let epoch = self.epoch.bump_assigned();
11723            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11724            let txn_id = self.alloc_txn_id()?;
11725            let column_json = DdlOp::encode_column(&column)?;
11726            let mut next_catalog = self.catalog.read().clone();
11727            let catalog_entry_index = next_catalog
11728                .tables
11729                .iter()
11730                .position(|entry| entry.table_id == table_id)
11731                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
11732            if let Some(new_column_name) = &renamed_column {
11733                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
11734                    commit_prepare_checkpoint(control, trigger_index)?;
11735                    if matches!(
11736                        &trigger.trigger.target,
11737                        TriggerTarget::Table(target) if target == table_name
11738                    ) {
11739                        trigger.trigger = trigger.trigger.renamed_update_column(
11740                            column_name,
11741                            new_column_name.clone(),
11742                            epoch.0,
11743                        )?;
11744                    }
11745                }
11746                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
11747                    commit_prepare_checkpoint(control, role_index)?;
11748                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
11749                        commit_prepare_checkpoint(control, permission_index)?;
11750                        rename_permission_column(
11751                            permission,
11752                            table_name,
11753                            column_name,
11754                            new_column_name,
11755                        );
11756                    }
11757                }
11758                advance_security_version(&mut next_catalog)?;
11759            }
11760            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
11761            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11762            commit_prepare_checkpoint(control, 0)?;
11763            let mut entered_fence = false;
11764            if let Some(before_commit) = before_commit.as_deref_mut() {
11765                before_commit()?;
11766                entered_fence = true;
11767            }
11768            let commit_result: Result<Epoch> = (|| {
11769                let commit_seq = {
11770                    let mut wal = self.shared_wal.lock();
11771                    let append: Result<u64> = (|| {
11772                        wal.append(
11773                            txn_id,
11774                            table_id,
11775                            crate::wal::Op::Ddl(DdlOp::AlterTable {
11776                                table_id,
11777                                column_json,
11778                            }),
11779                        )?;
11780                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11781                        wal.append_commit(txn_id, epoch, &[])
11782                    })();
11783                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11784                };
11785                self.await_durable_commit(commit_seq, epoch)?;
11786                durable_epoch.set(Some(epoch));
11787
11788                table.apply_altered_schema_prepared(prepared_schema);
11789                let schema = table.schema().clone();
11790                let table_checkpoint = table.checkpoint_altered_schema();
11791                drop(table);
11792                next_catalog.tables[catalog_entry_index].schema = schema;
11793                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11794                let catalog_result =
11795                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
11796                let security_version = next_catalog.security_version;
11797                *self.catalog.write() = next_catalog;
11798                if renamed_column.is_some() {
11799                    self.security_coordinator
11800                        .version
11801                        .store(security_version, Ordering::Release);
11802                }
11803                self.epoch.publish_in_order(epoch);
11804                _epoch_guard.disarm();
11805                if let Err(error) = table_checkpoint.and(catalog_result) {
11806                    self.poisoned.store(true, Ordering::Relaxed);
11807                    return Err(MongrelError::DurableCommit {
11808                        epoch: epoch.0,
11809                        message: error.to_string(),
11810                    });
11811                }
11812                Ok(epoch)
11813            })();
11814            let commit_result = if entered_fence {
11815                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11816            } else {
11817                commit_result
11818            };
11819            let epoch = commit_result?;
11820            Ok((column, Some(epoch)))
11821        })();
11822        result.map_err(|error| match (durable_epoch.get(), error) {
11823            (_, error @ MongrelError::DurableCommit { .. }) => error,
11824            (Some(epoch), error) => MongrelError::DurableCommit {
11825                epoch: epoch.0,
11826                message: error.to_string(),
11827            },
11828            (None, error) => error,
11829        })
11830    }
11831
11832    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
11833    /// replication. Duration is in nanoseconds.
11834    pub fn set_table_ttl(
11835        &self,
11836        table_name: &str,
11837        column_name: &str,
11838        duration_nanos: u64,
11839    ) -> Result<crate::manifest::TtlPolicy> {
11840        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
11841        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
11842    }
11843
11844    /// Set TTL metadata on a hidden build before it is published.
11845    #[doc(hidden)]
11846    pub fn set_building_table_ttl(
11847        &self,
11848        table_name: &str,
11849        column_name: &str,
11850        duration_nanos: u64,
11851    ) -> Result<crate::manifest::TtlPolicy> {
11852        let policy = self.replace_table_ttl_with_state(
11853            table_name,
11854            Some((column_name, duration_nanos)),
11855            true,
11856        )?;
11857        policy
11858            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
11859    }
11860
11861    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
11862        self.replace_table_ttl(table_name, None)?;
11863        Ok(())
11864    }
11865
11866    fn replace_table_ttl(
11867        &self,
11868        table_name: &str,
11869        requested: Option<(&str, u64)>,
11870    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11871        self.replace_table_ttl_with_state(table_name, requested, false)
11872    }
11873
11874    fn replace_table_ttl_with_state(
11875        &self,
11876        table_name: &str,
11877        requested: Option<(&str, u64)>,
11878        building: bool,
11879    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11880        use crate::wal::DdlOp;
11881        use std::sync::atomic::Ordering;
11882
11883        self.require(&crate::auth::Permission::Ddl)?;
11884        if self.poisoned.load(Ordering::Relaxed) {
11885            return Err(MongrelError::Other(
11886                "database poisoned by fsync error".into(),
11887            ));
11888        }
11889
11890        let _g = self.ddl_lock.lock();
11891        let _security_write = self.security_write()?;
11892        self.require(&crate::auth::Permission::Ddl)?;
11893        let table_id = {
11894            let cat = self.catalog.read();
11895            if building {
11896                cat.building(table_name)
11897            } else {
11898                cat.live(table_name)
11899            }
11900            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11901            .table_id
11902        };
11903        let handle =
11904            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11905                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11906            })?;
11907        let mut table = handle.lock();
11908        let policy = match requested {
11909            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
11910            None => None,
11911        };
11912        if table.ttl() == policy {
11913            return Ok(policy);
11914        }
11915
11916        let commit_lock = Arc::clone(&self.commit_lock);
11917        let _c = commit_lock.lock();
11918        let epoch = self.epoch.bump_assigned();
11919        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11920        let txn_id = self.alloc_txn_id()?;
11921        let policy_json = DdlOp::encode_ttl(policy)?;
11922        let mut next_catalog = self.catalog.read().clone();
11923        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11924        let commit_seq = {
11925            let mut wal = self.shared_wal.lock();
11926            let append: Result<u64> = (|| {
11927                wal.append(
11928                    txn_id,
11929                    table_id,
11930                    crate::wal::Op::Ddl(DdlOp::SetTtl {
11931                        table_id,
11932                        policy_json,
11933                    }),
11934                )?;
11935                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11936                wal.append_commit(txn_id, epoch, &[])
11937            })();
11938            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11939        };
11940        self.await_durable_commit(commit_seq, epoch)?;
11941
11942        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
11943        drop(table);
11944        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
11945            publish_error.get_or_insert(error);
11946        }
11947        self.finish_durable_publish(epoch, &mut _epoch_guard, publish_error.map_or(Ok(()), Err))?;
11948        Ok(policy)
11949    }
11950
11951    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
11952    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
11953    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
11954    ///
11955    /// Returns the number of items reclaimed.
11956    pub fn gc(&self) -> Result<usize> {
11957        let control = crate::ExecutionControl::new(None);
11958        self.gc_controlled(&control, || true)
11959    }
11960
11961    /// Discover reclaimable state cooperatively, then cross one publication
11962    /// boundary immediately before the first irreversible deletion.
11963    #[doc(hidden)]
11964    pub fn gc_controlled<F>(
11965        &self,
11966        control: &crate::ExecutionControl,
11967        before_publish: F,
11968    ) -> Result<usize>
11969    where
11970        F: FnOnce() -> bool,
11971    {
11972        self.gc_controlled_with_receipt(control, before_publish)
11973            .map(|(reclaimed, _)| reclaimed)
11974    }
11975
11976    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
11977    /// return that snapshot if an irreversible deletion was attempted.
11978    #[doc(hidden)]
11979    pub fn gc_controlled_with_receipt<F>(
11980        &self,
11981        control: &crate::ExecutionControl,
11982        before_publish: F,
11983    ) -> Result<(usize, Option<MaintenanceReceipt>)>
11984    where
11985        F: FnOnce() -> bool,
11986    {
11987        enum Candidate {
11988            Directory(PathBuf),
11989            File(PathBuf),
11990        }
11991
11992        self.require(&crate::auth::Permission::Ddl)?;
11993        let _ddl = self.ddl_lock.lock();
11994        self.require(&crate::auth::Permission::Ddl)?;
11995        control.checkpoint()?;
11996        let maintenance_epoch = self.epoch.visible();
11997        let min_active = self.snapshots.min_active(maintenance_epoch).0;
11998        let mut candidates = Vec::new();
11999
12000        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
12001        let cat = self.catalog.read();
12002        for (entry_index, entry) in cat.tables.iter().enumerate() {
12003            if entry_index % 256 == 0 {
12004                control.checkpoint()?;
12005            }
12006            if let TableState::Dropped { at_epoch } = entry.state {
12007                if at_epoch <= min_active {
12008                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
12009                    if tdir.exists() {
12010                        candidates.push(Candidate::Directory(tdir));
12011                    }
12012                }
12013            }
12014        }
12015        drop(cat);
12016
12017        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
12018        // in-flight spill's dir (deleting it would lose the pending run and fail
12019        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
12020        // skip any id still registered in `active_spills`.
12021        let cat = self.catalog.read();
12022        for (entry_index, entry) in cat.tables.iter().enumerate() {
12023            if entry_index % 256 == 0 {
12024                control.checkpoint()?;
12025            }
12026            if !matches!(entry.state, TableState::Live) {
12027                continue;
12028            }
12029            let txn_dir = self
12030                .root
12031                .join(TABLES_DIR)
12032                .join(entry.table_id.to_string())
12033                .join("_txn");
12034            if !txn_dir.exists() {
12035                continue;
12036            }
12037            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
12038                if sub_index % 256 == 0 {
12039                    control.checkpoint()?;
12040                }
12041                let sub = sub?;
12042                let name = sub.file_name();
12043                let Some(name) = name.to_str() else { continue };
12044                // A non-numeric entry can't belong to a live txn — sweep it.
12045                let is_active = name
12046                    .parse::<u64>()
12047                    .map(|id| self.active_spills.is_active(id))
12048                    .unwrap_or(false);
12049                if is_active {
12050                    continue;
12051                }
12052                candidates.push(Candidate::Directory(sub.path()));
12053            }
12054        }
12055        drop(cat);
12056
12057        let external_names = {
12058            let cat = self.catalog.read();
12059            cat.external_tables
12060                .iter()
12061                .map(|entry| entry.name.clone())
12062                .collect::<std::collections::HashSet<_>>()
12063        };
12064        let vtab_dir = self.root.join(VTAB_DIR);
12065        if vtab_dir.exists() {
12066            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
12067                if entry_index % 256 == 0 {
12068                    control.checkpoint()?;
12069                }
12070                let entry = entry?;
12071                let name = entry.file_name();
12072                let Some(name) = name.to_str() else { continue };
12073                if external_names.contains(name) {
12074                    continue;
12075                }
12076                let path = entry.path();
12077                if path.is_dir() {
12078                    candidates.push(Candidate::Directory(path));
12079                } else {
12080                    candidates.push(Candidate::File(path));
12081                }
12082            }
12083        }
12084
12085        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
12086        // can still need (spec §6.4). Each table deletes its own retired files
12087        // gated on `min_active` and persists its manifest.
12088        let tables = self
12089            .tables
12090            .read()
12091            .iter()
12092            .map(|(table_id, handle)| (*table_id, handle.clone()))
12093            .collect::<Vec<_>>();
12094        let mut retiring = Vec::new();
12095        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
12096            if table_index % 256 == 0 {
12097                control.checkpoint()?;
12098            }
12099            let backup_pinned: HashSet<u128> = self
12100                .backup_pins
12101                .lock()
12102                .keys()
12103                .filter_map(|(pinned_table, run_id)| {
12104                    (*pinned_table == *table_id).then_some(*run_id)
12105                })
12106                .collect();
12107            if handle
12108                .lock()
12109                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
12110            {
12111                retiring.push((handle.clone(), backup_pinned));
12112            }
12113        }
12114
12115        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
12116        // segment on every reopen without truncating the prior ones, so rotated
12117        // segments accumulate. Once every live table's committed data is durable
12118        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
12119        // (non-active) segments are redundant for recovery and safe to delete —
12120        // an in-flight txn only ever appends to the active segment, which is
12121        // never deleted.
12122        let all_durable = self.active_spills.is_idle()
12123            && tables.iter().all(|(_, handle)| {
12124                let g = handle.lock();
12125                g.memtable_len() == 0 && g.mutable_run_len() == 0
12126            });
12127        let retain = self
12128            .replication_wal_retention_segments
12129            .load(std::sync::atomic::Ordering::Relaxed);
12130        let reap_wal = all_durable
12131            && self
12132                .shared_wal
12133                .lock()
12134                .has_gc_segments_retain_recent(retain)?;
12135
12136        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
12137            return Ok((0, None));
12138        }
12139        control.checkpoint()?;
12140        if !before_publish() {
12141            return Err(MongrelError::Cancelled);
12142        }
12143
12144        let mut reclaimed = 0;
12145        for candidate in candidates {
12146            match candidate {
12147                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
12148                Candidate::File(path) => std::fs::remove_file(path)?,
12149            }
12150            reclaimed += 1;
12151        }
12152        for (handle, backup_pinned) in retiring {
12153            reclaimed += handle
12154                .lock()
12155                .reap_retiring(Epoch(min_active), &backup_pinned)?;
12156        }
12157        if reap_wal {
12158            reclaimed += self
12159                .shared_wal
12160                .lock()
12161                .gc_segments_retain_recent(u64::MAX, retain)?;
12162        }
12163
12164        Ok((
12165            reclaimed,
12166            Some(MaintenanceReceipt {
12167                epoch: maintenance_epoch,
12168            }),
12169        ))
12170    }
12171
12172    /// Produce a deterministic-stable byte image of the database directory.
12173    ///
12174    /// After `checkpoint()`:
12175    ///   - All pending writes are flushed to sorted runs (no memtable data).
12176    ///   - Each table is compacted to a single sorted run (no run fragmentation).
12177    ///   - All non-active WAL segments are deleted (data is durable in runs).
12178    ///   - The active WAL segment is rotated to a fresh empty segment.
12179    ///   - Dropped-table directories are removed.
12180    ///   - All manifests + catalog are persisted.
12181    ///
12182    /// The resulting directory is byte-stable: `git add` captures a snapshot
12183    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
12184    /// no unbounded segment growth, no mutable-run spill files.
12185    ///
12186    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
12187    /// It does NOT clear the exclusive lock — the caller still owns the
12188    /// database handle.
12189    pub fn checkpoint(&self) -> Result<()> {
12190        self.checkpoint_controlled(|| Ok(()))
12191    }
12192
12193    /// Strict checkpoint with a deterministic test hook after every table is
12194    /// flushed/compacted but before WAL replacement.
12195    #[doc(hidden)]
12196    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
12197    where
12198        F: FnOnce() -> Result<()>,
12199    {
12200        self.require(&crate::auth::Permission::Ddl)?;
12201        // Block cross-table commits and DDL for the full operation. Locking all
12202        // mounted handles also excludes direct `Table` commits, which do not
12203        // enter the database replication barrier.
12204        let _replication = self.replication_barrier.write();
12205        let _ddl = self.ddl_lock.lock();
12206        let _security = self.security_coordinator.gate.read();
12207        self.require(&crate::auth::Permission::Ddl)?;
12208
12209        let mut handles = self
12210            .tables
12211            .read()
12212            .iter()
12213            .map(|(table_id, handle)| (*table_id, handle.clone()))
12214            .collect::<Vec<_>>();
12215        handles.sort_by_key(|(table_id, _)| *table_id);
12216        let mut tables = handles
12217            .iter()
12218            .map(|(table_id, handle)| (*table_id, handle.lock()))
12219            .collect::<Vec<_>>();
12220
12221        // Strict flush. Any error leaves the old WAL recovery source intact.
12222        for (_, table) in &mut tables {
12223            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
12224            {
12225                table.force_flush()?;
12226            }
12227        }
12228
12229        // Strict compaction. Checkpoint never reports a stable image after a
12230        // skipped failure.
12231        for (_, table) in &mut tables {
12232            if table.run_count() >= 2 || table.should_compact() {
12233                table.compact()?;
12234            }
12235        }
12236
12237        before_wal_reset()?;
12238
12239        // Reap table-local retired runs while every table remains quiesced.
12240        let maintenance_epoch = self.epoch.visible();
12241        let min_active = self.snapshots.min_active(maintenance_epoch);
12242        for (table_id, table) in &mut tables {
12243            let backup_pinned: HashSet<u128> = self
12244                .backup_pins
12245                .lock()
12246                .keys()
12247                .filter_map(|(pinned_table, run_id)| {
12248                    (*pinned_table == *table_id).then_some(*run_id)
12249                })
12250                .collect();
12251            table.reap_retiring(min_active, &backup_pinned)?;
12252        }
12253
12254        // Publish a fresh synced active WAL, then durably reap every older
12255        // segment. This point is reached only after every strict flush succeeds.
12256        self.shared_wal.lock().reset_after_checkpoint()?;
12257
12258        // Remove catalog-unreachable directories and stale transaction state.
12259        let catalog_snapshot = self.catalog.read().clone();
12260        for entry in &catalog_snapshot.tables {
12261            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
12262                crate::durable_file::remove_directory_all(
12263                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
12264                )?;
12265            }
12266            if !matches!(entry.state, TableState::Live) {
12267                continue;
12268            }
12269            let transaction_dir = self
12270                .root
12271                .join(TABLES_DIR)
12272                .join(entry.table_id.to_string())
12273                .join("_txn");
12274            if transaction_dir.is_dir() {
12275                for child in std::fs::read_dir(&transaction_dir)? {
12276                    let child = child?;
12277                    let active = child
12278                        .file_name()
12279                        .to_str()
12280                        .and_then(|name| name.parse::<u64>().ok())
12281                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
12282                    if !active {
12283                        crate::durable_file::remove_directory_all(&child.path())?;
12284                    }
12285                }
12286            }
12287        }
12288        let external_names = catalog_snapshot
12289            .external_tables
12290            .iter()
12291            .map(|entry| entry.name.as_str())
12292            .collect::<HashSet<_>>();
12293        let external_root = self.root.join(VTAB_DIR);
12294        if external_root.is_dir() {
12295            for entry in std::fs::read_dir(&external_root)? {
12296                let entry = entry?;
12297                let name = entry.file_name();
12298                if name
12299                    .to_str()
12300                    .is_some_and(|name| external_names.contains(name))
12301                {
12302                    continue;
12303                }
12304                if entry.file_type()?.is_dir() {
12305                    crate::durable_file::remove_directory_all(&entry.path())?;
12306                } else {
12307                    std::fs::remove_file(entry.path())?;
12308                    crate::durable_file::sync_directory(&external_root)?;
12309                }
12310            }
12311        }
12312
12313        // Final authoritative metadata checkpoint while all writers remain
12314        // excluded.
12315        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
12316        let visible = self.epoch.visible();
12317        for (_, table) in &tables {
12318            table.persist_manifest(visible)?;
12319        }
12320
12321        Ok(())
12322    }
12323    fn alloc_txn_id(&self) -> Result<u64> {
12324        self.ensure_owner_process()?;
12325        crate::txn::allocate_txn_id(&self.next_txn_id)
12326    }
12327
12328    /// Set the per-table spill threshold (bytes). When a transaction's staged
12329    /// bytes for a single table exceed this, the rows are written as a
12330    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
12331    pub fn set_spill_threshold(&self, bytes: u64) {
12332        self.spill_threshold
12333            .store(bytes, std::sync::atomic::Ordering::Relaxed);
12334    }
12335
12336    /// Test-only: install a hook invoked after a transaction writes its spill
12337    /// runs but before the sequencer, so a test can race `gc()` against an
12338    /// in-flight spill. Not part of the stable API.
12339    #[doc(hidden)]
12340    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12341        *self.spill_hook.lock() = Some(Box::new(f));
12342    }
12343
12344    /// Test-only: install a hook invoked while a spilled commit holds the
12345    /// security read gate and before it appends to the WAL.
12346    #[doc(hidden)]
12347    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12348        *self.security_commit_hook.lock() = Some(Box::new(f));
12349    }
12350
12351    /// Test-only: install a hook after transaction preparation and before the
12352    /// commit sequencer validates catalog generations.
12353    #[doc(hidden)]
12354    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12355        *self.catalog_commit_hook.lock() = Some(Box::new(f));
12356    }
12357
12358    /// Test-only: pause an online backup after its consistent boundary is
12359    /// captured but before the pinned immutable runs are copied.
12360    #[doc(hidden)]
12361    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12362        *self.backup_hook.lock() = Some(Box::new(f));
12363    }
12364
12365    /// Test-only: pause WAL extraction before its final principal recheck.
12366    #[doc(hidden)]
12367    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12368        *self.replication_hook.lock() = Some(Box::new(f));
12369    }
12370
12371    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
12372    /// this stays well below the number of committed transactions when commits
12373    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
12374    #[doc(hidden)]
12375    pub fn __wal_group_sync_count(&self) -> u64 {
12376        self.shared_wal.lock().group_sync_count()
12377    }
12378
12379    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
12380    /// contract that an fsync error would trigger in production.
12381    #[doc(hidden)]
12382    pub fn __poison(&self) {
12383        self.poisoned
12384            .store(true, std::sync::atomic::Ordering::Relaxed);
12385    }
12386
12387    /// Verify multi-table integrity (spec §16). For every live table this:
12388    /// authenticates the manifest; opens each `RunRef`'s file through
12389    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
12390    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
12391    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
12392    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
12393    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
12394    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
12395    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
12396    ///
12397    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
12398    /// full body, so this is an integrity tool, not a hot path.
12399    pub fn check(&self) -> Vec<CheckIssue> {
12400        match self.check_inner(None) {
12401            Ok(issues) => issues,
12402            Err(error) => vec![CheckIssue {
12403                table_id: WAL_TABLE_ID,
12404                table_name: "shared WAL".into(),
12405                severity: "error".into(),
12406                description: error.to_string(),
12407            }],
12408        }
12409    }
12410
12411    /// Integrity check with cooperative cancellation between tables and runs.
12412    #[doc(hidden)]
12413    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
12414        self.check_inner(Some(control))
12415    }
12416
12417    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
12418        let mut issues = Vec::new();
12419        let cat = self.catalog.read();
12420        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
12421        for (table_index, entry) in cat.tables.iter().enumerate() {
12422            if table_index % 256 == 0 {
12423                if let Some(control) = control {
12424                    control.checkpoint()?;
12425                }
12426            }
12427            if !matches!(entry.state, TableState::Live) {
12428                continue;
12429            }
12430            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
12431            let mut err = |sev: &str, desc: String| {
12432                issues.push(CheckIssue {
12433                    table_id: entry.table_id,
12434                    table_name: entry.name.clone(),
12435                    severity: sev.into(),
12436                    description: desc,
12437                });
12438            };
12439            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
12440                Ok(m) => m,
12441                Err(e) => {
12442                    err("error", format!("manifest read failed: {e}"));
12443                    continue;
12444                }
12445            };
12446            if m.flushed_epoch > m.current_epoch {
12447                err(
12448                    "error",
12449                    format!(
12450                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
12451                        m.flushed_epoch, m.current_epoch
12452                    ),
12453                );
12454            }
12455
12456            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
12457            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
12458            for (run_index, rr) in m.runs.iter().enumerate() {
12459                if run_index % 256 == 0 {
12460                    if let Some(control) = control {
12461                        control.checkpoint()?;
12462                    }
12463                }
12464                referenced.insert(rr.run_id);
12465                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
12466                if !run_path.exists() {
12467                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
12468                    continue;
12469                }
12470                match crate::sorted_run::RunReader::open(
12471                    &run_path,
12472                    entry.schema.clone(),
12473                    self.kek.clone(),
12474                ) {
12475                    Ok(reader) => {
12476                        if reader.row_count() as u64 != rr.row_count {
12477                            err(
12478                                "error",
12479                                format!(
12480                                    "run r-{} row count mismatch: manifest {} vs run {}",
12481                                    rr.run_id,
12482                                    rr.row_count,
12483                                    reader.row_count()
12484                                ),
12485                            );
12486                        }
12487                    }
12488                    Err(e) => {
12489                        err(
12490                            "error",
12491                            format!("run r-{} integrity check failed: {e}", rr.run_id),
12492                        );
12493                    }
12494                }
12495            }
12496
12497            // Compaction-superseded runs awaiting retention-gated deletion are
12498            // tracked in `retiring`; their files are expected on disk, so they
12499            // are not orphans.
12500            for r in &m.retiring {
12501                referenced.insert(r.run_id);
12502            }
12503
12504            // Orphan `.sr` files present on disk but absent from the manifest.
12505            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
12506                for (entry_index, ent) in rd.flatten().enumerate() {
12507                    if entry_index % 256 == 0 {
12508                        if let Some(control) = control {
12509                            control.checkpoint()?;
12510                        }
12511                    }
12512                    let p = ent.path();
12513                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
12514                        continue;
12515                    }
12516                    let run_id = p
12517                        .file_stem()
12518                        .and_then(|s| s.to_str())
12519                        .and_then(|s| s.strip_prefix("r-"))
12520                        .and_then(|s| s.parse::<u128>().ok());
12521                    if let Some(id) = run_id {
12522                        if !referenced.contains(&id) {
12523                            err(
12524                                "warning",
12525                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
12526                            );
12527                        }
12528                    }
12529                }
12530            }
12531        }
12532
12533        let external_names = cat
12534            .external_tables
12535            .iter()
12536            .map(|entry| entry.name.clone())
12537            .collect::<std::collections::HashSet<_>>();
12538        let vtab_dir = self.root.join(VTAB_DIR);
12539        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
12540            for (entry_index, entry) in entries.flatten().enumerate() {
12541                if entry_index % 256 == 0 {
12542                    if let Some(control) = control {
12543                        control.checkpoint()?;
12544                    }
12545                }
12546                let name = entry.file_name();
12547                let Some(name) = name.to_str() else { continue };
12548                if !external_names.contains(name) {
12549                    issues.push(CheckIssue {
12550                        table_id: EXTERNAL_TABLE_ID,
12551                        table_name: name.to_string(),
12552                        severity: "warning".into(),
12553                        description: format!(
12554                            "orphan external table state entry {:?} not referenced by the catalog",
12555                            entry.path()
12556                        ),
12557                    });
12558                }
12559            }
12560        }
12561
12562        // WAL retention / integrity invariant (spec §16): every on-disk WAL
12563        // segment must open (header magic + version, and the frame cipher must
12564        // be derivable for an encrypted WAL). A segment that won't open is
12565        // corrupt or truncated and would break crash recovery. `table_id` is
12566        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
12567        // never confuses a WAL issue with a real table.
12568        if let Some(control) = control {
12569            control.checkpoint()?;
12570        }
12571        for (seg, msg) in self.shared_wal.lock().verify_segments() {
12572            issues.push(CheckIssue {
12573                table_id: WAL_TABLE_ID,
12574                table_name: "<wal>".into(),
12575                severity: "error".into(),
12576                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
12577            });
12578        }
12579        Ok(issues)
12580    }
12581
12582    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
12583    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
12584    /// unmounts them from the live table map so the DB still opens.
12585    pub fn doctor(&self) -> Result<Vec<u64>> {
12586        let control = crate::ExecutionControl::new(None);
12587        self.doctor_controlled(&control, || true)
12588    }
12589
12590    /// Check cancellably, then fence immediately before the first quarantine
12591    /// mutation. Returning `false` from `before_publish` leaves the database
12592    /// untouched.
12593    #[doc(hidden)]
12594    pub fn doctor_controlled<F>(
12595        &self,
12596        control: &crate::ExecutionControl,
12597        before_publish: F,
12598    ) -> Result<Vec<u64>>
12599    where
12600        F: FnOnce() -> bool,
12601    {
12602        self.doctor_controlled_with_receipt(control, before_publish)
12603            .map(|(quarantined, _)| quarantined)
12604    }
12605
12606    /// Check cancellably and return the exact catalog epoch used for a
12607    /// quarantine publication. No receipt is returned when nothing changes.
12608    #[doc(hidden)]
12609    pub fn doctor_controlled_with_receipt<F>(
12610        &self,
12611        control: &crate::ExecutionControl,
12612        before_publish: F,
12613    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
12614    where
12615        F: FnOnce() -> bool,
12616    {
12617        // Hold the DDL lock for the whole operation to prevent concurrent
12618        // create_table/drop_table from racing the catalog/dir mutation.
12619        let _ddl = self.ddl_lock.lock();
12620        let _security_write = self.security_write()?;
12621        let issues = self.check_inner(Some(control))?;
12622        // A corrupt WAL segment is reported as an error but is NOT a table
12623        // problem — quarantining an innocent table cannot fix it (and the first
12624        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
12625        // them disjoint). The admin must address WAL corruption manually.
12626        let bad_tables: std::collections::HashSet<u64> = issues
12627            .iter()
12628            .filter(|i| {
12629                i.severity == "error"
12630                    && i.table_id != WAL_TABLE_ID
12631                    && i.table_id != EXTERNAL_TABLE_ID
12632            })
12633            .map(|i| i.table_id)
12634            .collect();
12635        if bad_tables.is_empty() {
12636            return Ok((Vec::new(), None));
12637        }
12638        let _commit = self.commit_lock.lock();
12639        control.checkpoint()?;
12640        if !before_publish() {
12641            return Err(MongrelError::Cancelled);
12642        }
12643        let maintenance_epoch = self.epoch.bump_assigned();
12644        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
12645
12646        let qdir = self.root.join("_quarantine");
12647        crate::durable_file::create_directory(&qdir)?;
12648        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
12649        bad_tables.sort_unstable();
12650
12651        // Quiesce every mounted target before catalog publication. Existing
12652        // handle clones are marked unavailable in the publication callback so
12653        // they cannot append to the shared WAL after their catalog entry drops.
12654        let mut handles = self
12655            .tables
12656            .read()
12657            .iter()
12658            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
12659            .map(|(table_id, handle)| (*table_id, handle.clone()))
12660            .collect::<Vec<_>>();
12661        handles.sort_by_key(|(table_id, _)| *table_id);
12662        let mut table_guards = handles
12663            .iter()
12664            .map(|(table_id, handle)| (*table_id, handle.lock()))
12665            .collect::<Vec<_>>();
12666
12667        let mut next_catalog = self.catalog.read().clone();
12668        for table_id in &bad_tables {
12669            if let Some(entry) = next_catalog
12670                .tables
12671                .iter_mut()
12672                .find(|entry| entry.table_id == *table_id)
12673            {
12674                entry.state = TableState::Dropped {
12675                    at_epoch: maintenance_epoch.0,
12676                };
12677            }
12678        }
12679        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
12680
12681        let txn_id = self.alloc_txn_id()?;
12682        let commit_seq = {
12683            let mut wal = self.shared_wal.lock();
12684            let append: Result<u64> = (|| {
12685                for table_id in &bad_tables {
12686                    wal.append(
12687                        txn_id,
12688                        *table_id,
12689                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
12690                            table_id: *table_id,
12691                        }),
12692                    )?;
12693                }
12694                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12695                wal.append_commit(txn_id, maintenance_epoch, &[])
12696            })();
12697            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
12698        };
12699        self.await_durable_commit(commit_seq, maintenance_epoch)?;
12700        for (_, table) in &mut table_guards {
12701            table.mark_unavailable_after_quarantine();
12702        }
12703        {
12704            let mut live_tables = self.tables.write();
12705            for table_id in &bad_tables {
12706                live_tables.remove(table_id);
12707            }
12708        }
12709        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
12710        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, checkpoint)?;
12711
12712        // The catalog drop is durable. Directory placement is secondary but
12713        // still uses a write-through rename. A failure reports the known
12714        // catalog outcome and leaves a harmless orphan under `tables/`.
12715        for table_id in &bad_tables {
12716            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
12717            if source.exists() {
12718                let destination = qdir.join(table_id.to_string());
12719                if let Err(error) = crate::durable_file::rename(&source, &destination) {
12720                    return Err(MongrelError::DurableCommit {
12721                        epoch: maintenance_epoch.0,
12722                        message: format!(
12723                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
12724                        ),
12725                    });
12726                }
12727            }
12728        }
12729        Ok((
12730            bad_tables,
12731            Some(MaintenanceReceipt {
12732                epoch: maintenance_epoch,
12733            }),
12734        ))
12735    }
12736
12737    /// The DB-wide KEK (if encrypted).
12738    #[allow(dead_code)]
12739    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
12740        self.kek.as_ref()
12741    }
12742
12743    /// Shared epoch authority (used by the transaction layer in P2).
12744    #[allow(dead_code)]
12745    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
12746        &self.epoch
12747    }
12748
12749    /// Shared snapshot registry (used by GC in P3.6).
12750    #[allow(dead_code)]
12751    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
12752        &self.snapshots
12753    }
12754}
12755
12756fn external_state_dir(root: &Path, name: &str) -> PathBuf {
12757    root.join(VTAB_DIR).join(name)
12758}
12759
12760fn append_catalog_snapshot(
12761    wal: &mut crate::wal::SharedWal,
12762    txn_id: u64,
12763    catalog: &Catalog,
12764) -> Result<()> {
12765    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
12766    wal.append(
12767        txn_id,
12768        WAL_TABLE_ID,
12769        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
12770    )?;
12771    Ok(())
12772}
12773
12774fn filter_ignored_staging(
12775    staging: Vec<(u64, crate::txn::Staged)>,
12776    ignored_indices: &std::collections::BTreeSet<usize>,
12777) -> Vec<(u64, crate::txn::Staged)> {
12778    if ignored_indices.is_empty() {
12779        return staging;
12780    }
12781    staging
12782        .into_iter()
12783        .enumerate()
12784        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
12785        .collect()
12786}
12787
12788fn external_state_file(root: &Path, name: &str) -> PathBuf {
12789    external_state_dir(root, name).join("state.json")
12790}
12791
12792fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
12793    let path = external_state_file(root, name);
12794    match std::fs::read(path) {
12795        Ok(bytes) => Ok(bytes),
12796        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
12797        Err(e) => Err(e.into()),
12798    }
12799}
12800
12801fn current_external_state_bytes(
12802    root: &Path,
12803    external_states: &[(String, Vec<u8>)],
12804    name: &str,
12805) -> Result<Vec<u8>> {
12806    for (table, state) in external_states.iter().rev() {
12807        if table == name {
12808            return Ok(state.clone());
12809        }
12810    }
12811    read_external_state_file(root, name)
12812}
12813
12814fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
12815    let mut out = external_states;
12816    dedup_external_states_in_place(&mut out);
12817    out
12818}
12819
12820fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
12821    let mut seen = std::collections::HashSet::new();
12822    let mut out = Vec::with_capacity(external_states.len());
12823    for (name, state) in std::mem::take(external_states).into_iter().rev() {
12824        if seen.insert(name.clone()) {
12825            out.push((name, state));
12826        }
12827    }
12828    out.reverse();
12829    *external_states = out;
12830}
12831
12832fn prepare_external_state_file(
12833    root: &Path,
12834    name: &str,
12835    state: &[u8],
12836    txn_id: u64,
12837) -> Result<PathBuf> {
12838    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
12839    let dir = external_state_dir(root, name);
12840    crate::durable_file::create_directory(&dir)?;
12841    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
12842    {
12843        let mut file = std::fs::OpenOptions::new()
12844            .create_new(true)
12845            .write(true)
12846            .open(&pending)?;
12847        file.write_all(state)?;
12848        file.sync_all()?;
12849    }
12850    Ok(pending)
12851}
12852
12853fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
12854    let path = external_state_file(root, name);
12855    crate::durable_file::replace(pending, &path)?;
12856    Ok(())
12857}
12858
12859fn write_external_state_file(
12860    durable: &crate::durable_file::DurableRoot,
12861    name: &str,
12862    state: &[u8],
12863) -> Result<()> {
12864    let directory = Path::new(VTAB_DIR).join(name);
12865    durable.create_directory_all(&directory)?;
12866    durable.write_atomic(directory.join("state.json"), state)?;
12867    Ok(())
12868}
12869
12870fn validate_recovered_data_table(
12871    catalog: &Catalog,
12872    tables: &HashMap<u64, TableHandle>,
12873    table_id: u64,
12874    commit_epoch: u64,
12875    offset: u64,
12876) -> Result<bool> {
12877    let entry = catalog
12878        .tables
12879        .iter()
12880        .find(|entry| entry.table_id == table_id)
12881        .ok_or_else(|| MongrelError::CorruptWal {
12882            offset,
12883            reason: format!("committed record references unknown table {table_id}"),
12884        })?;
12885    if commit_epoch < entry.created_epoch {
12886        return Err(MongrelError::CorruptWal {
12887            offset,
12888            reason: format!(
12889                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12890                entry.created_epoch
12891            ),
12892        });
12893    }
12894    match entry.state {
12895        TableState::Dropped { at_epoch } => {
12896            // Abandoned hidden builds are marked dropped at the last durable
12897            // boundary during open, so their final build commit may equal the
12898            // cleanup epoch. Ordinary table drops consume a new epoch and must
12899            // remain strictly later than every data commit.
12900            let abandoned_build_boundary =
12901                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12902            if commit_epoch >= at_epoch && !abandoned_build_boundary {
12903                Err(MongrelError::CorruptWal {
12904                    offset,
12905                    reason: format!(
12906                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
12907                    ),
12908                })
12909            } else {
12910                Ok(false)
12911            }
12912        }
12913        TableState::Live | TableState::Building { .. } => {
12914            if tables.contains_key(&table_id) {
12915                Ok(true)
12916            } else {
12917                Err(MongrelError::CorruptWal {
12918                    offset,
12919                    reason: format!("live table {table_id} has no mounted recovery handle"),
12920                })
12921            }
12922        }
12923    }
12924}
12925
12926type RecoveryTableStage = (
12927    Vec<crate::memtable::Row>,
12928    Vec<(crate::rowid::RowId, Epoch)>,
12929    Option<Epoch>,
12930    Epoch,
12931);
12932
12933#[derive(Clone)]
12934struct RecoveryValidationTable {
12935    schema: Schema,
12936    flushed_epoch: u64,
12937}
12938
12939fn validate_shared_wal_recovery_plan(
12940    durable_root: &crate::durable_file::DurableRoot,
12941    catalog: &Catalog,
12942    recovered_table_ids: &HashSet<u64>,
12943    reconciled_table_ids: &HashSet<u64>,
12944    meta_dek: Option<&[u8; META_DEK_LEN]>,
12945    kek: Option<Arc<crate::encryption::Kek>>,
12946    records: &[crate::wal::Record],
12947) -> Result<()> {
12948    use crate::wal::{DdlOp, Op};
12949
12950    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
12951    for entry in &catalog.tables {
12952        if !matches!(entry.state, TableState::Live) {
12953            continue;
12954        }
12955        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
12956        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
12957            Ok(manifest) => Some(manifest),
12958            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
12959            Err(error) => return Err(error),
12960        };
12961        let flushed_epoch = if let Some(manifest) = manifest {
12962            if manifest.table_id != entry.table_id {
12963                return Err(MongrelError::Conflict(format!(
12964                    "catalog table {} storage identity mismatch",
12965                    entry.table_id
12966                )));
12967            }
12968            if (manifest.schema_id != entry.schema.schema_id
12969                && !reconciled_table_ids.contains(&entry.table_id))
12970                || manifest.flushed_epoch > manifest.current_epoch
12971                || manifest.global_idx_epoch > manifest.current_epoch
12972                || manifest.next_row_id == u64::MAX
12973                || manifest.auto_inc_next < 0
12974                || manifest.auto_inc_next == i64::MAX
12975                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
12976            {
12977                return Err(MongrelError::InvalidArgument(format!(
12978                    "table {} manifest counters or schema identity are invalid",
12979                    entry.table_id
12980                )));
12981            }
12982            #[cfg(feature = "encryption")]
12983            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
12984            #[cfg(not(feature = "encryption"))]
12985            let idx_dek: Option<zeroize::Zeroizing<[u8; 32]>> = None;
12986            crate::global_idx::read_durable_for(
12987                durable_root,
12988                &relative_dir,
12989                entry.table_id,
12990                &entry.schema,
12991                idx_dek.as_deref(),
12992            )?;
12993            let mut run_ids = HashSet::new();
12994            let mut maximum_row_id = None::<u64>;
12995            for run in &manifest.runs {
12996                if run.run_id >= u64::MAX as u128
12997                    || run.epoch_created > manifest.current_epoch
12998                    || !run_ids.insert(run.run_id)
12999                {
13000                    return Err(MongrelError::InvalidArgument(format!(
13001                        "table {} manifest contains an invalid or duplicate run id",
13002                        entry.table_id
13003                    )));
13004                }
13005                let relative = relative_dir
13006                    .join(crate::engine::RUNS_DIR)
13007                    .join(format!("r-{}.sr", run.run_id as u64));
13008                let file = durable_root.open_regular(&relative)?;
13009                let mut reader = crate::sorted_run::RunReader::open_file(
13010                    file,
13011                    entry.schema.clone(),
13012                    kek.clone(),
13013                )?;
13014                let header = reader.header();
13015                if header.run_id != run.run_id
13016                    || header.level != run.level
13017                    || header.row_count != run.row_count
13018                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
13019                    || header.is_uniform_epoch() && header.epoch_created != 0
13020                    || header.schema_id > entry.schema.schema_id
13021                {
13022                    return Err(MongrelError::InvalidArgument(format!(
13023                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
13024                        entry.table_id,
13025                        run.run_id,
13026                        header.run_id,
13027                        header.level,
13028                        header.row_count,
13029                        header.epoch_created,
13030                        header.schema_id,
13031                        run.run_id,
13032                        run.level,
13033                        run.row_count,
13034                        run.epoch_created,
13035                        entry.schema.schema_id,
13036                    )));
13037                }
13038                if header.row_count != 0 {
13039                    maximum_row_id = Some(
13040                        maximum_row_id
13041                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
13042                    );
13043                }
13044                reader.validate_all_pages()?;
13045            }
13046            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
13047                return Err(MongrelError::InvalidArgument(format!(
13048                    "table {} next_row_id does not advance beyond persisted rows",
13049                    entry.table_id
13050                )));
13051            }
13052            for run in &manifest.retiring {
13053                if run.run_id >= u64::MAX as u128
13054                    || run.retire_epoch > manifest.current_epoch
13055                    || !run_ids.insert(run.run_id)
13056                {
13057                    return Err(MongrelError::InvalidArgument(format!(
13058                        "table {} manifest contains an invalid or aliased retired run",
13059                        entry.table_id
13060                    )));
13061                }
13062            }
13063            manifest.flushed_epoch
13064        } else {
13065            if !recovered_table_ids.contains(&entry.table_id) {
13066                return Err(MongrelError::NotFound(format!(
13067                    "live table {} manifest is missing",
13068                    entry.table_id
13069                )));
13070            }
13071            0
13072        };
13073        tables.insert(
13074            entry.table_id,
13075            RecoveryValidationTable {
13076                schema: entry.schema.clone(),
13077                flushed_epoch,
13078            },
13079        );
13080    }
13081
13082    let committed = records
13083        .iter()
13084        .filter_map(|record| match record.op {
13085            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
13086            _ => None,
13087        })
13088        .collect::<HashMap<_, _>>();
13089    let mut run_ids = HashSet::new();
13090    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
13091    for record in records {
13092        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
13093            continue;
13094        };
13095        match &record.op {
13096            Op::Put { table_id, rows } => {
13097                let table = validate_recovery_data_table_plan(
13098                    catalog,
13099                    &tables,
13100                    *table_id,
13101                    commit_epoch,
13102                    record.seq.0,
13103                )?;
13104                let decoded: Vec<crate::memtable::Row> =
13105                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
13106                        offset: record.seq.0,
13107                        reason: format!(
13108                            "committed Put payload for transaction {} could not be decoded: {error}",
13109                            record.txn_id
13110                        ),
13111                    })?;
13112                if let Some(table) = table {
13113                    for row in &decoded {
13114                        if !recovered_row_ids
13115                            .entry(*table_id)
13116                            .or_default()
13117                            .insert(row.row_id.0)
13118                        {
13119                            return Err(MongrelError::CorruptWal {
13120                                offset: record.seq.0,
13121                                reason: format!(
13122                                    "committed WAL repeats recovered row id {} for table {table_id}",
13123                                    row.row_id.0
13124                                ),
13125                            });
13126                        }
13127                        validate_recovered_row(&table.schema, row)?;
13128                    }
13129                }
13130            }
13131            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
13132                validate_recovery_data_table_plan(
13133                    catalog,
13134                    &tables,
13135                    *table_id,
13136                    commit_epoch,
13137                    record.seq.0,
13138                )?;
13139            }
13140            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
13141            Op::Ddl(DdlOp::ResetExternalTableState {
13142                name,
13143                generation_epoch,
13144            }) => {
13145                if *generation_epoch != commit_epoch {
13146                    return Err(MongrelError::CorruptWal {
13147                        offset: record.seq.0,
13148                        reason: format!(
13149                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
13150                        ),
13151                    });
13152                }
13153                validate_recovered_external_name(name)?;
13154            }
13155            Op::TxnCommit { added_runs, .. } => {
13156                for added in added_runs {
13157                    let Some(table) = validate_recovery_data_table_plan(
13158                        catalog,
13159                        &tables,
13160                        added.table_id,
13161                        commit_epoch,
13162                        record.seq.0,
13163                    )?
13164                    else {
13165                        continue;
13166                    };
13167                    if added.run_id >= u64::MAX as u128
13168                        || !run_ids.insert((added.table_id, added.run_id))
13169                    {
13170                        return Err(MongrelError::CorruptWal {
13171                            offset: record.seq.0,
13172                            reason: format!(
13173                                "duplicate or invalid recovered run {} for table {}",
13174                                added.run_id, added.table_id
13175                            ),
13176                        });
13177                    }
13178                    if commit_epoch <= table.flushed_epoch {
13179                        continue;
13180                    }
13181                    validate_planned_spilled_run(
13182                        durable_root,
13183                        record.txn_id,
13184                        commit_epoch,
13185                        added,
13186                        &table.schema,
13187                        kek.clone(),
13188                    )?;
13189                }
13190            }
13191            _ => {}
13192        }
13193    }
13194    Ok(())
13195}
13196
13197fn validate_recovery_data_table_plan<'a>(
13198    catalog: &Catalog,
13199    tables: &'a HashMap<u64, RecoveryValidationTable>,
13200    table_id: u64,
13201    commit_epoch: u64,
13202    offset: u64,
13203) -> Result<Option<&'a RecoveryValidationTable>> {
13204    let entry = catalog
13205        .tables
13206        .iter()
13207        .find(|entry| entry.table_id == table_id)
13208        .ok_or_else(|| MongrelError::CorruptWal {
13209            offset,
13210            reason: format!("committed record references unknown table {table_id}"),
13211        })?;
13212    if commit_epoch < entry.created_epoch {
13213        return Err(MongrelError::CorruptWal {
13214            offset,
13215            reason: format!(
13216                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
13217                entry.created_epoch
13218            ),
13219        });
13220    }
13221    match entry.state {
13222        TableState::Dropped { at_epoch } => {
13223            let abandoned =
13224                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
13225            if commit_epoch >= at_epoch && !abandoned {
13226                return Err(MongrelError::CorruptWal {
13227                    offset,
13228                    reason: format!(
13229                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
13230                    ),
13231                });
13232            }
13233            Ok(None)
13234        }
13235        TableState::Live => {
13236            tables
13237                .get(&table_id)
13238                .map(Some)
13239                .ok_or_else(|| MongrelError::CorruptWal {
13240                    offset,
13241                    reason: format!("live table {table_id} has no recovery plan"),
13242                })
13243        }
13244        TableState::Building { .. } => Err(MongrelError::CorruptWal {
13245            offset,
13246            reason: format!("building table {table_id} was not normalized before recovery"),
13247        }),
13248    }
13249}
13250
13251fn validate_planned_spilled_run(
13252    root: &crate::durable_file::DurableRoot,
13253    txn_id: u64,
13254    commit_epoch: u64,
13255    added: &crate::wal::AddedRun,
13256    schema: &Schema,
13257    kek: Option<Arc<crate::encryption::Kek>>,
13258) -> Result<()> {
13259    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
13260    let destination = table
13261        .join(crate::engine::RUNS_DIR)
13262        .join(format!("r-{}.sr", added.run_id as u64));
13263    let pending = table
13264        .join("_txn")
13265        .join(txn_id.to_string())
13266        .join(format!("r-{}.sr", added.run_id as u64));
13267    let file = match root.open_regular(&destination) {
13268        Ok(file) => file,
13269        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13270            root.open_regular(&pending).map_err(|pending_error| {
13271                if pending_error.kind() == std::io::ErrorKind::NotFound {
13272                    MongrelError::CorruptWal {
13273                        offset: commit_epoch,
13274                        reason: format!(
13275                            "committed spilled run {} for transaction {txn_id} is missing",
13276                            added.run_id
13277                        ),
13278                    }
13279                } else {
13280                    pending_error.into()
13281                }
13282            })?
13283        }
13284        Err(error) => return Err(error.into()),
13285    };
13286    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
13287    let header = reader.header();
13288    if header.run_id != added.run_id
13289        || header.content_hash != added.content_hash
13290        || header.row_count != added.row_count
13291        || header.level != added.level
13292        || header.min_row_id != added.min_row_id
13293        || header.max_row_id != added.max_row_id
13294        || header.schema_id != schema.schema_id
13295        || !header.is_uniform_epoch()
13296        || header.epoch_created != 0
13297    {
13298        return Err(MongrelError::CorruptWal {
13299            offset: commit_epoch,
13300            reason: format!(
13301                "committed spilled run {} metadata differs from WAL",
13302                added.run_id
13303            ),
13304        });
13305    }
13306    reader.validate_all_pages()?;
13307    Ok(())
13308}
13309
13310/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
13311///
13312/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
13313/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
13314/// 2 applies each committed data record (Put/Delete) to its table at the commit
13315/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
13316/// durable in a sorted run). Finally the shared epoch authority is raised to the
13317/// max committed epoch so the next commit continues monotonically.
13318fn recover_shared_wal(
13319    durable_root: &crate::durable_file::DurableRoot,
13320    tables: &HashMap<u64, TableHandle>,
13321    catalog: &Catalog,
13322    epoch: &EpochAuthority,
13323    records: &[crate::wal::Record],
13324) -> Result<()> {
13325    use crate::memtable::Row;
13326    use crate::wal::{DdlOp, Op};
13327
13328    // Pass 1: committed-txn outcomes + collect spilled-run info.
13329    let mut committed: HashMap<u64, u64> = HashMap::new();
13330    let mut spilled_to_link: Vec<(
13331        u64, /*txn_id*/
13332        u64, /*epoch*/
13333        Vec<crate::wal::AddedRun>,
13334    )> = Vec::new();
13335    for r in records {
13336        if let Op::TxnCommit {
13337            epoch: ce,
13338            ref added_runs,
13339        } = r.op
13340        {
13341            committed.insert(r.txn_id, ce);
13342            if !added_runs.is_empty() {
13343                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
13344            }
13345        }
13346    }
13347    for record in records {
13348        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
13349            continue;
13350        };
13351        match &record.op {
13352            Op::Put { table_id, .. }
13353            | Op::Delete { table_id, .. }
13354            | Op::TruncateTable { table_id } => {
13355                validate_recovered_data_table(
13356                    catalog,
13357                    tables,
13358                    *table_id,
13359                    commit_epoch,
13360                    record.seq.0,
13361                )?;
13362            }
13363            Op::TxnCommit { added_runs, .. } => {
13364                for run in added_runs {
13365                    validate_recovered_data_table(
13366                        catalog,
13367                        tables,
13368                        run.table_id,
13369                        commit_epoch,
13370                        record.seq.0,
13371                    )?;
13372                }
13373            }
13374            _ => {}
13375        }
13376    }
13377    let truncated_transactions: HashSet<(u64, u64)> = records
13378        .iter()
13379        .filter_map(|record| {
13380            committed.get(&record.txn_id)?;
13381            match record.op {
13382                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
13383                _ => None,
13384            }
13385        })
13386        .collect();
13387
13388    // Pass 2: stage data per table, gated by flushed_epoch.
13389    enum ExternalRecoveryAction {
13390        Write { name: String, state: Vec<u8> },
13391        Reset { name: String },
13392    }
13393    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
13394    let mut external_actions = Vec::new();
13395    let mut max_epoch = epoch.visible().0;
13396    for r in records.iter().cloned() {
13397        let Some(&ce) = committed.get(&r.txn_id) else {
13398            continue; // aborted / in-flight — discard
13399        };
13400        let commit_epoch = Epoch(ce);
13401        max_epoch = max_epoch.max(ce);
13402        match r.op {
13403            Op::Put { table_id, rows } => {
13404                // Skip if this table already flushed past the commit epoch.
13405                let skip = tables
13406                    .get(&table_id)
13407                    .map(|h| h.lock().flushed_epoch() >= ce)
13408                    .unwrap_or(true);
13409                if skip {
13410                    continue;
13411                }
13412                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
13413                    MongrelError::CorruptWal {
13414                        offset: r.seq.0,
13415                        reason: format!(
13416                            "committed Put payload for transaction {} could not be decoded: {error}",
13417                            r.txn_id
13418                        ),
13419                    }
13420                })?;
13421                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
13422                // at pending_epoch which equals the commit epoch, but be robust).
13423                let rows: Vec<Row> = rows
13424                    .into_iter()
13425                    .map(|mut row| {
13426                        row.committed_epoch = commit_epoch;
13427                        row
13428                    })
13429                    .collect();
13430                let entry = stage
13431                    .entry(table_id)
13432                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13433                entry.0.extend(rows);
13434                entry.3 = commit_epoch;
13435            }
13436            Op::Delete { table_id, row_ids } => {
13437                let skip = tables
13438                    .get(&table_id)
13439                    .map(|h| h.lock().flushed_epoch() >= ce)
13440                    .unwrap_or(true);
13441                if skip {
13442                    continue;
13443                }
13444                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
13445                let entry = stage
13446                    .entry(table_id)
13447                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13448                entry.1.extend(dels);
13449                entry.3 = commit_epoch;
13450            }
13451            Op::TruncateTable { table_id } => {
13452                let skip = tables
13453                    .get(&table_id)
13454                    .map(|h| h.lock().flushed_epoch() >= ce)
13455                    .unwrap_or(true);
13456                if skip {
13457                    continue;
13458                }
13459                stage.insert(
13460                    table_id,
13461                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
13462                );
13463            }
13464            Op::ExternalTableState { name, state } => {
13465                let current_generation = catalog
13466                    .external_tables
13467                    .iter()
13468                    .find(|entry| entry.name == name)
13469                    .map(|entry| entry.created_epoch);
13470                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
13471                    validate_recovered_external_name(&name)?;
13472                    external_actions.push(ExternalRecoveryAction::Write { name, state });
13473                }
13474            }
13475            Op::Ddl(DdlOp::ResetExternalTableState {
13476                name,
13477                generation_epoch,
13478            }) => {
13479                if generation_epoch != ce {
13480                    return Err(MongrelError::CorruptWal {
13481                        offset: r.seq.0,
13482                        reason: format!(
13483                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
13484                    ),
13485                    });
13486                }
13487                validate_recovered_external_name(&name)?;
13488                external_actions.push(ExternalRecoveryAction::Reset { name });
13489            }
13490            Op::Flush { .. }
13491            | Op::TxnCommit { .. }
13492            | Op::TxnAbort
13493            | Op::Ddl(_)
13494            | Op::BeforeImage { .. }
13495            | Op::CommitTimestamp { .. }
13496            | Op::SpilledRows { .. } => {}
13497        }
13498    }
13499    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
13500        added_runs.retain(|added| {
13501            tables
13502                .get(&added.table_id)
13503                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
13504        });
13505    }
13506    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
13507    validate_recovery_table_stages(tables, &stage)?;
13508    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
13509
13510    // All WAL payloads, catalog generations, table stages, and immutable run
13511    // identities have now been validated. Only this application phase mutates
13512    // the database tree.
13513    for action in external_actions {
13514        match action {
13515            ExternalRecoveryAction::Write { name, state } => {
13516                write_external_state_file(durable_root, &name, &state)?;
13517            }
13518            ExternalRecoveryAction::Reset { name } => {
13519                durable_root.create_directory_all(VTAB_DIR)?;
13520                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
13521            }
13522        }
13523    }
13524    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
13525        let Some(handle) = tables.get(&table_id) else {
13526            continue;
13527        };
13528        let mut t = handle.lock();
13529        if let Some(epoch) = truncate_epoch {
13530            t.apply_truncate(epoch);
13531        }
13532        t.recover_apply(rows, deletes)?;
13533        // The WAL can be newer than the copied/persisted manifest after a
13534        // crash or replication apply. Rebuild O(1) count metadata from the
13535        // recovered state before endorsing the commit epoch in the manifest.
13536        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13537        t.live_count = rows.len() as u64;
13538        // Recovery can replay older row commits while a newer spilled run is
13539        // already linked by the copied manifest. Never move that manifest's
13540        // epoch behind its existing run references.
13541        t.persist_manifest(table_epoch.max(epoch.visible()))?;
13542    }
13543
13544    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
13545    // between TxnCommit sync and the publish phase leaves the run in
13546    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
13547    for (txn_id, ce, added_runs) in &spilled_to_link {
13548        for ar in added_runs {
13549            let Some(handle) = tables.get(&ar.table_id) else {
13550                continue;
13551            };
13552            let mut t = handle.lock();
13553            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
13554            let destination = table_dir
13555                .join(crate::engine::RUNS_DIR)
13556                .join(format!("r-{}.sr", ar.run_id));
13557            match durable_root.open_regular(&destination) {
13558                Ok(_) => {}
13559                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13560                    let pending = table_dir
13561                        .join("_txn")
13562                        .join(txn_id.to_string())
13563                        .join(format!("r-{}.sr", ar.run_id));
13564                    durable_root.rename_file_new(&pending, &destination)?;
13565                }
13566                Err(error) => return Err(error.into()),
13567            }
13568            // Only link a run whose file is actually present, and never re-link
13569            // one the publish phase already persisted into the manifest (which is
13570            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
13571            // until segment GC). `recover_spilled_run` is idempotent + reconciles
13572            // `live_count`/indexes only when the run is genuinely new.
13573            let linked = t.recover_spilled_run(crate::manifest::RunRef {
13574                run_id: ar.run_id,
13575                level: ar.level,
13576                epoch_created: *ce,
13577                row_count: ar.row_count,
13578            });
13579            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
13580            if replaced {
13581                t.set_flushed_epoch(Epoch(*ce));
13582            }
13583            if linked || replaced {
13584                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
13585            }
13586        }
13587    }
13588
13589    epoch.advance_recovered(Epoch(max_epoch));
13590    Ok(())
13591}
13592
13593fn reconcile_recovered_table_metadata(
13594    tables: &HashMap<u64, TableHandle>,
13595    epoch: Epoch,
13596) -> Result<()> {
13597    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
13598    table_ids.sort_unstable();
13599    let mut plans = Vec::with_capacity(table_ids.len());
13600    for table_id in &table_ids {
13601        let handle = tables.get(table_id).ok_or_else(|| {
13602            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13603        })?;
13604        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
13605    }
13606    // Every table's data and metadata have been decoded successfully. Publish
13607    // repairs only after the complete database-wide plan is known valid.
13608    for (table_id, plan) in plans {
13609        let handle = tables.get(&table_id).ok_or_else(|| {
13610            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13611        })?;
13612        handle.lock().apply_recovered_metadata(plan, epoch)?;
13613    }
13614    Ok(())
13615}
13616
13617fn validate_recovered_external_name(name: &str) -> Result<()> {
13618    if name.is_empty()
13619        || !name.chars().all(|character| {
13620            character.is_ascii_alphanumeric() || character == '_' || character == '-'
13621        })
13622    {
13623        return Err(MongrelError::CorruptWal {
13624            offset: 0,
13625            reason: format!("unsafe recovered external-table name {name:?}"),
13626        });
13627    }
13628    Ok(())
13629}
13630
13631fn validate_recovery_table_stages(
13632    tables: &HashMap<u64, TableHandle>,
13633    stages: &HashMap<u64, RecoveryTableStage>,
13634) -> Result<()> {
13635    for (table_id, (rows, _, _, _)) in stages {
13636        let handle = tables
13637            .get(table_id)
13638            .ok_or_else(|| MongrelError::CorruptWal {
13639                offset: *table_id,
13640                reason: format!("recovery stage references unmounted table {table_id}"),
13641            })?;
13642        let table = handle.lock();
13643        // Force all existing immutable runs through their integrity/decode path
13644        // before any other table manifest can be changed.
13645        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13646        for row in rows {
13647            validate_recovered_row(table.schema(), row)?;
13648        }
13649    }
13650    Ok(())
13651}
13652
13653fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
13654    if row.deleted || row.row_id.0 == u64::MAX {
13655        return Err(MongrelError::CorruptWal {
13656            offset: row.row_id.0,
13657            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
13658        });
13659    }
13660    let cells = row
13661        .columns
13662        .iter()
13663        .map(|(column, value)| (*column, value.clone()))
13664        .collect::<Vec<_>>();
13665    schema
13666        .validate_persisted_values(&cells)
13667        .map_err(|error| MongrelError::CorruptWal {
13668            offset: row.row_id.0,
13669            reason: format!("recovered row violates table schema: {error}"),
13670        })?;
13671    if schema.auto_increment_column().is_some_and(|column| {
13672        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
13673    }) {
13674        return Err(MongrelError::CorruptWal {
13675            offset: row.row_id.0,
13676            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
13677        });
13678    }
13679    Ok(())
13680}
13681
13682fn validate_recovery_spilled_runs(
13683    root: &crate::durable_file::DurableRoot,
13684    tables: &HashMap<u64, TableHandle>,
13685    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
13686) -> Result<()> {
13687    let mut identities = HashSet::new();
13688    for (txn_id, commit_epoch, added_runs) in spilled {
13689        for added in added_runs {
13690            if added.run_id >= u64::MAX as u128 {
13691                return Err(MongrelError::CorruptWal {
13692                    offset: *commit_epoch,
13693                    reason: format!(
13694                        "recovered run id {} exceeds the on-disk namespace",
13695                        added.run_id
13696                    ),
13697                });
13698            }
13699            let Some(handle) = tables.get(&added.table_id) else {
13700                continue;
13701            };
13702            if !identities.insert((added.table_id, added.run_id)) {
13703                return Err(MongrelError::CorruptWal {
13704                    offset: *commit_epoch,
13705                    reason: format!(
13706                        "duplicate recovered run {} for table {}",
13707                        added.run_id, added.table_id
13708                    ),
13709                });
13710            }
13711            let table = handle.lock();
13712            validate_planned_spilled_run(
13713                root,
13714                *txn_id,
13715                *commit_epoch,
13716                added,
13717                table.schema(),
13718                table.kek(),
13719            )?;
13720        }
13721    }
13722    Ok(())
13723}
13724
13725fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
13726    match condition {
13727        ProcedureCondition::Pk { .. } => {
13728            if schema.primary_key().is_none() {
13729                return Err(MongrelError::InvalidArgument(
13730                    "procedure condition Pk references a table without a primary key".into(),
13731                ));
13732            }
13733        }
13734        ProcedureCondition::BitmapEq { column_id, .. }
13735        | ProcedureCondition::BitmapIn { column_id, .. }
13736        | ProcedureCondition::Range { column_id, .. }
13737        | ProcedureCondition::RangeF64 { column_id, .. }
13738        | ProcedureCondition::IsNull { column_id }
13739        | ProcedureCondition::IsNotNull { column_id }
13740        | ProcedureCondition::FmContains { column_id, .. } => {
13741            validate_column_id(*column_id, schema)?;
13742        }
13743    }
13744    Ok(())
13745}
13746
13747fn bind_procedure_args(
13748    procedure: &StoredProcedure,
13749    mut args: HashMap<String, crate::Value>,
13750) -> Result<HashMap<String, crate::Value>> {
13751    let mut out = HashMap::new();
13752    for param in &procedure.params {
13753        let value = match args.remove(&param.name) {
13754            Some(value) => value,
13755            None => param.default.clone().ok_or_else(|| {
13756                MongrelError::InvalidArgument(format!(
13757                    "missing required procedure parameter {:?}",
13758                    param.name
13759                ))
13760            })?,
13761        };
13762        if !param.nullable && matches!(value, crate::Value::Null) {
13763            return Err(MongrelError::InvalidArgument(format!(
13764                "procedure parameter {:?} must not be NULL",
13765                param.name
13766            )));
13767        }
13768        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
13769            return Err(MongrelError::InvalidArgument(format!(
13770                "procedure parameter {:?} has wrong type",
13771                param.name
13772            )));
13773        }
13774        out.insert(param.name.clone(), value);
13775    }
13776    if let Some(extra) = args.keys().next() {
13777        return Err(MongrelError::InvalidArgument(format!(
13778            "unknown procedure parameter {extra:?}"
13779        )));
13780    }
13781    Ok(out)
13782}
13783
13784fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
13785    matches!(
13786        (value, ty),
13787        (crate::Value::Bool(_), crate::TypeId::Bool)
13788            | (crate::Value::Int64(_), crate::TypeId::Int8)
13789            | (crate::Value::Int64(_), crate::TypeId::Int16)
13790            | (crate::Value::Int64(_), crate::TypeId::Int32)
13791            | (crate::Value::Int64(_), crate::TypeId::Int64)
13792            | (crate::Value::Int64(_), crate::TypeId::UInt8)
13793            | (crate::Value::Int64(_), crate::TypeId::UInt16)
13794            | (crate::Value::Int64(_), crate::TypeId::UInt32)
13795            | (crate::Value::Int64(_), crate::TypeId::UInt64)
13796            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
13797            | (crate::Value::Int64(_), crate::TypeId::Date32)
13798            | (crate::Value::Float64(_), crate::TypeId::Float32)
13799            | (crate::Value::Float64(_), crate::TypeId::Float64)
13800            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
13801            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
13802    )
13803}
13804
13805fn eval_cells(
13806    cells: &[crate::procedure::ProcedureCell],
13807    args: &HashMap<String, crate::Value>,
13808    outputs: &HashMap<String, ProcedureCallOutput>,
13809) -> Result<Vec<(u16, crate::Value)>> {
13810    cells
13811        .iter()
13812        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
13813        .collect()
13814}
13815
13816fn eval_condition(
13817    condition: &ProcedureCondition,
13818    args: &HashMap<String, crate::Value>,
13819    outputs: &HashMap<String, ProcedureCallOutput>,
13820) -> Result<crate::Condition> {
13821    Ok(match condition {
13822        ProcedureCondition::Pk { value } => {
13823            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
13824        }
13825        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
13826            column_id: *column_id,
13827            value: eval_value(value, args, outputs)?.encode_key(),
13828        },
13829        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
13830            column_id: *column_id,
13831            values: values
13832                .iter()
13833                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
13834                .collect::<Result<Vec<_>>>()?,
13835        },
13836        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
13837            column_id: *column_id,
13838            lo: expect_i64(eval_value(lo, args, outputs)?)?,
13839            hi: expect_i64(eval_value(hi, args, outputs)?)?,
13840        },
13841        ProcedureCondition::RangeF64 {
13842            column_id,
13843            lo,
13844            lo_inclusive,
13845            hi,
13846            hi_inclusive,
13847        } => crate::Condition::RangeF64 {
13848            column_id: *column_id,
13849            lo: expect_f64(eval_value(lo, args, outputs)?)?,
13850            lo_inclusive: *lo_inclusive,
13851            hi: expect_f64(eval_value(hi, args, outputs)?)?,
13852            hi_inclusive: *hi_inclusive,
13853        },
13854        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
13855            column_id: *column_id,
13856        },
13857        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
13858            column_id: *column_id,
13859        },
13860        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
13861            column_id: *column_id,
13862            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
13863        },
13864    })
13865}
13866
13867fn eval_value(
13868    value: &ProcedureValue,
13869    args: &HashMap<String, crate::Value>,
13870    outputs: &HashMap<String, ProcedureCallOutput>,
13871) -> Result<crate::Value> {
13872    match value {
13873        ProcedureValue::Literal(value) => Ok(value.clone()),
13874        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
13875            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13876        }),
13877        ProcedureValue::StepScalar(id) => match outputs.get(id) {
13878            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
13879            _ => Err(MongrelError::InvalidArgument(format!(
13880                "procedure step {id:?} did not return a scalar"
13881            ))),
13882        },
13883        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
13884            Err(MongrelError::InvalidArgument(
13885                "row-valued procedure reference cannot be used as a scalar".into(),
13886            ))
13887        }
13888        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
13889            "structured procedure value cannot be used as a scalar cell".into(),
13890        )),
13891    }
13892}
13893
13894fn eval_return_output(
13895    value: &ProcedureValue,
13896    args: &HashMap<String, crate::Value>,
13897    outputs: &HashMap<String, ProcedureCallOutput>,
13898) -> Result<ProcedureCallOutput> {
13899    match value {
13900        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
13901        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
13902            args.get(name).cloned().ok_or_else(|| {
13903                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13904            })?,
13905        )),
13906        ProcedureValue::StepRows(id)
13907        | ProcedureValue::StepRow(id)
13908        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
13909            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
13910        }),
13911        ProcedureValue::Object(fields) => {
13912            let mut out = Vec::with_capacity(fields.len());
13913            for (name, value) in fields {
13914                out.push((name.clone(), eval_return_output(value, args, outputs)?));
13915            }
13916            Ok(ProcedureCallOutput::Object(out))
13917        }
13918        ProcedureValue::Array(values) => {
13919            let mut out = Vec::with_capacity(values.len());
13920            for value in values {
13921                out.push(eval_return_output(value, args, outputs)?);
13922            }
13923            Ok(ProcedureCallOutput::Array(out))
13924        }
13925    }
13926}
13927
13928fn expect_i64(value: crate::Value) -> Result<i64> {
13929    match value {
13930        crate::Value::Int64(value) => Ok(value),
13931        _ => Err(MongrelError::InvalidArgument(
13932            "procedure value must be Int64".into(),
13933        )),
13934    }
13935}
13936
13937fn expect_f64(value: crate::Value) -> Result<f64> {
13938    match value {
13939        crate::Value::Float64(value) => Ok(value),
13940        _ => Err(MongrelError::InvalidArgument(
13941            "procedure value must be Float64".into(),
13942        )),
13943    }
13944}
13945
13946fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
13947    match value {
13948        crate::Value::Bytes(value) => Ok(value),
13949        _ => Err(MongrelError::InvalidArgument(
13950            "procedure value must be Bytes".into(),
13951        )),
13952    }
13953}
13954
13955fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
13956    if schema.columns.iter().any(|c| c.id == column_id) {
13957        Ok(())
13958    } else {
13959        Err(MongrelError::InvalidArgument(format!(
13960            "unknown column id {column_id}"
13961        )))
13962    }
13963}
13964
13965fn trigger_matches_event(
13966    trigger: &StoredTrigger,
13967    event: &WriteEvent,
13968    cat: &Catalog,
13969) -> Result<bool> {
13970    if trigger.event != event.kind {
13971        return Ok(false);
13972    }
13973    let TriggerTarget::Table(target) = &trigger.target else {
13974        return Ok(false);
13975    };
13976    if target != &event.table {
13977        return Ok(false);
13978    }
13979    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
13980        let schema = &cat
13981            .live(target)
13982            .ok_or_else(|| {
13983                MongrelError::InvalidArgument(format!(
13984                    "trigger {:?} references unknown table {target:?}",
13985                    trigger.name
13986                ))
13987            })?
13988            .schema;
13989        let mut watched = Vec::with_capacity(trigger.update_of.len());
13990        for name in &trigger.update_of {
13991            let col = schema.column(name).ok_or_else(|| {
13992                MongrelError::InvalidArgument(format!(
13993                    "trigger {:?} references unknown UPDATE OF column {name:?}",
13994                    trigger.name
13995                ))
13996            })?;
13997            watched.push(col.id);
13998        }
13999        if !event
14000            .changed_columns
14001            .iter()
14002            .any(|column_id| watched.contains(column_id))
14003        {
14004            return Ok(false);
14005        }
14006    }
14007    Ok(true)
14008}
14009
14010fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
14011    let mut ids = std::collections::BTreeSet::new();
14012    if let Some(old) = old {
14013        ids.extend(old.columns.keys().copied());
14014    }
14015    if let Some(new) = new {
14016        ids.extend(new.columns.keys().copied());
14017    }
14018    ids.into_iter()
14019        .filter(|id| {
14020            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
14021        })
14022        .collect()
14023}
14024
14025fn eval_trigger_cells(
14026    cells: &[crate::trigger::TriggerCell],
14027    event: &WriteEvent,
14028    selected: Option<&TriggerRowImage>,
14029) -> Result<Vec<(u16, Value)>> {
14030    cells
14031        .iter()
14032        .map(|cell| {
14033            Ok((
14034                cell.column_id,
14035                eval_trigger_value(&cell.value, event, selected)?,
14036            ))
14037        })
14038        .collect()
14039}
14040
14041fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
14042    match expr {
14043        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
14044            Value::Bool(value) => Ok(value),
14045            Value::Null => Ok(false),
14046            other => Err(MongrelError::InvalidArgument(format!(
14047                "trigger WHEN value must be boolean, got {other:?}"
14048            ))),
14049        },
14050        TriggerExpr::Eq { left, right } => Ok(values_equal(
14051            &eval_trigger_value(left, event, None)?,
14052            &eval_trigger_value(right, event, None)?,
14053        )),
14054        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
14055            &eval_trigger_value(left, event, None)?,
14056            &eval_trigger_value(right, event, None)?,
14057        )),
14058        TriggerExpr::Lt { left, right } => match value_order(
14059            &eval_trigger_value(left, event, None)?,
14060            &eval_trigger_value(right, event, None)?,
14061        ) {
14062            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
14063            None => Ok(false),
14064        },
14065        TriggerExpr::Lte { left, right } => match value_order(
14066            &eval_trigger_value(left, event, None)?,
14067            &eval_trigger_value(right, event, None)?,
14068        ) {
14069            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
14070            None => Ok(false),
14071        },
14072        TriggerExpr::Gt { left, right } => match value_order(
14073            &eval_trigger_value(left, event, None)?,
14074            &eval_trigger_value(right, event, None)?,
14075        ) {
14076            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
14077            None => Ok(false),
14078        },
14079        TriggerExpr::Gte { left, right } => match value_order(
14080            &eval_trigger_value(left, event, None)?,
14081            &eval_trigger_value(right, event, None)?,
14082        ) {
14083            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
14084            None => Ok(false),
14085        },
14086        TriggerExpr::IsNull(value) => Ok(matches!(
14087            eval_trigger_value(value, event, None)?,
14088            Value::Null
14089        )),
14090        TriggerExpr::IsNotNull(value) => Ok(!matches!(
14091            eval_trigger_value(value, event, None)?,
14092            Value::Null
14093        )),
14094        TriggerExpr::And { left, right } => {
14095            if !eval_trigger_expr(left, event)? {
14096                Ok(false)
14097            } else {
14098                Ok(eval_trigger_expr(right, event)?)
14099            }
14100        }
14101        TriggerExpr::Or { left, right } => {
14102            if eval_trigger_expr(left, event)? {
14103                Ok(true)
14104            } else {
14105                Ok(eval_trigger_expr(right, event)?)
14106            }
14107        }
14108        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
14109    }
14110}
14111
14112fn eval_trigger_condition(
14113    condition: &TriggerCondition,
14114    event: &WriteEvent,
14115    selected: &TriggerRowImage,
14116    schema: &Schema,
14117) -> Result<bool> {
14118    match condition {
14119        TriggerCondition::Pk { value } => {
14120            let pk = schema.primary_key().ok_or_else(|| {
14121                MongrelError::InvalidArgument(
14122                    "trigger condition Pk references a table without a primary key".into(),
14123                )
14124            })?;
14125            let lhs = eval_trigger_value(value, event, Some(selected))?;
14126            Ok(values_equal(
14127                &lhs,
14128                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
14129            ))
14130        }
14131        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
14132            selected.columns.get(column_id).unwrap_or(&Value::Null),
14133            &eval_trigger_value(value, event, Some(selected))?,
14134        )),
14135        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
14136            selected.columns.get(column_id).unwrap_or(&Value::Null),
14137            &eval_trigger_value(value, event, Some(selected))?,
14138        )),
14139        TriggerCondition::Lt { column_id, value } => match value_order(
14140            selected.columns.get(column_id).unwrap_or(&Value::Null),
14141            &eval_trigger_value(value, event, Some(selected))?,
14142        ) {
14143            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
14144            None => Ok(false),
14145        },
14146        TriggerCondition::Lte { column_id, value } => match value_order(
14147            selected.columns.get(column_id).unwrap_or(&Value::Null),
14148            &eval_trigger_value(value, event, Some(selected))?,
14149        ) {
14150            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
14151            None => Ok(false),
14152        },
14153        TriggerCondition::Gt { column_id, value } => match value_order(
14154            selected.columns.get(column_id).unwrap_or(&Value::Null),
14155            &eval_trigger_value(value, event, Some(selected))?,
14156        ) {
14157            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
14158            None => Ok(false),
14159        },
14160        TriggerCondition::Gte { column_id, value } => match value_order(
14161            selected.columns.get(column_id).unwrap_or(&Value::Null),
14162            &eval_trigger_value(value, event, Some(selected))?,
14163        ) {
14164            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
14165            None => Ok(false),
14166        },
14167        TriggerCondition::IsNull { column_id } => Ok(matches!(
14168            selected.columns.get(column_id),
14169            None | Some(Value::Null)
14170        )),
14171        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
14172            selected.columns.get(column_id),
14173            None | Some(Value::Null)
14174        )),
14175        TriggerCondition::And { left, right } => {
14176            if !eval_trigger_condition(left, event, selected, schema)? {
14177                Ok(false)
14178            } else {
14179                Ok(eval_trigger_condition(right, event, selected, schema)?)
14180            }
14181        }
14182        TriggerCondition::Or { left, right } => {
14183            if eval_trigger_condition(left, event, selected, schema)? {
14184                Ok(true)
14185            } else {
14186                Ok(eval_trigger_condition(right, event, selected, schema)?)
14187            }
14188        }
14189        TriggerCondition::Not(condition) => {
14190            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
14191        }
14192    }
14193}
14194
14195fn eval_trigger_value(
14196    value: &TriggerValue,
14197    event: &WriteEvent,
14198    selected: Option<&TriggerRowImage>,
14199) -> Result<Value> {
14200    match value {
14201        TriggerValue::Literal(value) => Ok(value.clone()),
14202        TriggerValue::NewColumn(column_id) => event
14203            .new
14204            .as_ref()
14205            .and_then(|row| row.columns.get(column_id))
14206            .cloned()
14207            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
14208        TriggerValue::OldColumn(column_id) => event
14209            .old
14210            .as_ref()
14211            .and_then(|row| row.columns.get(column_id))
14212            .cloned()
14213            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
14214        TriggerValue::SelectedColumn(column_id) => selected
14215            .and_then(|row| row.columns.get(column_id))
14216            .cloned()
14217            .ok_or_else(|| {
14218                MongrelError::InvalidArgument("SELECTED column is not available".into())
14219            }),
14220    }
14221}
14222
14223fn values_equal(left: &Value, right: &Value) -> bool {
14224    match (left, right) {
14225        (Value::Null, Value::Null) => true,
14226        (Value::Bool(a), Value::Bool(b)) => a == b,
14227        (Value::Int64(a), Value::Int64(b)) => a == b,
14228        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
14229        (Value::Bytes(a), Value::Bytes(b)) => a == b,
14230        (Value::Embedding(a), Value::Embedding(b)) => {
14231            a.len() == b.len()
14232                && a.iter()
14233                    .zip(b.iter())
14234                    .all(|(a, b)| a.to_bits() == b.to_bits())
14235        }
14236        _ => false,
14237    }
14238}
14239
14240fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
14241    match (left, right) {
14242        (Value::Null, _) | (_, Value::Null) => None,
14243        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
14244        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
14245        // Cross-type Int64/Float64 comparison coerces the integer to f64.
14246        // This matches the spec but can lose precision for i64 values above 2^53.
14247        (Value::Int64(a), Value::Float64(b)) => {
14248            let af = *a as f64;
14249            Some(af.total_cmp(b))
14250        }
14251        // Cross-type Int64/Float64 comparison coerces the integer to f64.
14252        // This matches the spec but can lose precision for i64 values above 2^53.
14253        (Value::Float64(a), Value::Int64(b)) => {
14254            let bf = *b as f64;
14255            Some(a.total_cmp(&bf))
14256        }
14257        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
14258        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
14259        (Value::Embedding(_), Value::Embedding(_)) => None,
14260        _ => None,
14261    }
14262}
14263
14264fn trigger_message(value: Value) -> String {
14265    match value {
14266        Value::Null => "NULL".into(),
14267        Value::Bool(value) => value.to_string(),
14268        Value::Int64(value) => value.to_string(),
14269        Value::Float64(value) => value.to_string(),
14270        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
14271        Value::Embedding(value) => format!("{value:?}"),
14272        Value::Decimal(value) => value.to_string(),
14273        Value::Interval {
14274            months,
14275            days,
14276            nanos,
14277        } => format!("{months}m {days}d {nanos}ns"),
14278        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
14279        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
14280    }
14281}
14282
14283fn validate_trigger_step<'a>(
14284    step: &TriggerStep,
14285    cat: &'a Catalog,
14286    target_schema: &Schema,
14287    event: TriggerEvent,
14288    select_schemas: &mut HashMap<String, &'a Schema>,
14289) -> Result<()> {
14290    match step {
14291        TriggerStep::SetNew { cells } => {
14292            if event == TriggerEvent::Delete {
14293                return Err(MongrelError::InvalidArgument(
14294                    "SetNew trigger step is not valid for DELETE triggers".into(),
14295                ));
14296            }
14297            for cell in cells {
14298                validate_column_id(cell.column_id, target_schema)?;
14299                validate_trigger_value(&cell.value, target_schema, event)?;
14300            }
14301        }
14302        TriggerStep::Insert { table, cells } => {
14303            let schema = trigger_write_schema(cat, table, "insert")?;
14304            for cell in cells {
14305                validate_column_id(cell.column_id, schema)?;
14306                validate_trigger_value(&cell.value, target_schema, event)?;
14307            }
14308        }
14309        TriggerStep::UpdateByPk { table, pk, cells } => {
14310            let schema = trigger_write_schema(cat, table, "update")?;
14311            if schema.primary_key().is_none() {
14312                return Err(MongrelError::InvalidArgument(format!(
14313                    "trigger update_by_pk references table {table:?} without a primary key"
14314                )));
14315            }
14316            validate_trigger_value(pk, target_schema, event)?;
14317            for cell in cells {
14318                validate_column_id(cell.column_id, schema)?;
14319                validate_trigger_value(&cell.value, target_schema, event)?;
14320            }
14321        }
14322        TriggerStep::DeleteByPk { table, pk } => {
14323            let schema = trigger_write_schema(cat, table, "delete")?;
14324            if schema.primary_key().is_none() {
14325                return Err(MongrelError::InvalidArgument(format!(
14326                    "trigger delete_by_pk references table {table:?} without a primary key"
14327                )));
14328            }
14329            validate_trigger_value(pk, target_schema, event)?;
14330        }
14331        TriggerStep::Select {
14332            id,
14333            table,
14334            conditions,
14335        } => {
14336            let schema = trigger_read_schema(cat, table)?;
14337            for condition in conditions {
14338                validate_trigger_condition(condition, schema, target_schema, event)?;
14339            }
14340            if select_schemas.contains_key(id) {
14341                return Err(MongrelError::InvalidArgument(format!(
14342                    "duplicate select id {id:?} in trigger program"
14343                )));
14344            }
14345            select_schemas.insert(id.clone(), schema);
14346        }
14347        TriggerStep::Foreach { id, steps } => {
14348            if !select_schemas.contains_key(id) {
14349                return Err(MongrelError::InvalidArgument(format!(
14350                    "foreach references unknown select id {id:?}"
14351                )));
14352            }
14353            let mut inner_select_schemas = select_schemas.clone();
14354            for step in steps {
14355                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
14356            }
14357        }
14358        TriggerStep::DeleteWhere { table, conditions } => {
14359            let schema = trigger_write_schema(cat, table, "delete")?;
14360            for condition in conditions {
14361                validate_trigger_condition(condition, schema, target_schema, event)?;
14362            }
14363        }
14364        TriggerStep::UpdateWhere {
14365            table,
14366            conditions,
14367            cells,
14368        } => {
14369            let schema = trigger_write_schema(cat, table, "update")?;
14370            for condition in conditions {
14371                validate_trigger_condition(condition, schema, target_schema, event)?;
14372            }
14373            for cell in cells {
14374                validate_column_id(cell.column_id, schema)?;
14375                validate_trigger_value(&cell.value, target_schema, event)?;
14376            }
14377        }
14378        TriggerStep::Raise { message, .. } => {
14379            validate_trigger_value(message, target_schema, event)?
14380        }
14381    }
14382    Ok(())
14383}
14384
14385fn trigger_validation_error(error: MongrelError) -> MongrelError {
14386    match error {
14387        MongrelError::TriggerValidation(_) => error,
14388        MongrelError::InvalidArgument(message)
14389        | MongrelError::Conflict(message)
14390        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
14391        error => error,
14392    }
14393}
14394
14395fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
14396    if let Some(entry) = cat.live(table) {
14397        return Ok(&entry.schema);
14398    }
14399    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14400        let allowed = match op {
14401            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
14402            "update" | "delete" => entry.capabilities.writable,
14403            _ => false,
14404        };
14405        if !allowed {
14406            return Err(MongrelError::InvalidArgument(format!(
14407                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
14408                entry.module
14409            )));
14410        }
14411        if !entry.capabilities.transaction_safe {
14412            return Err(MongrelError::InvalidArgument(format!(
14413                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
14414                entry.module
14415            )));
14416        }
14417        return Ok(&entry.declared_schema);
14418    }
14419    Err(MongrelError::InvalidArgument(format!(
14420        "trigger references unknown table {table:?}"
14421    )))
14422}
14423
14424fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
14425    if let Some(entry) = cat.live(table) {
14426        return Ok(&entry.schema);
14427    }
14428    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14429        if entry.capabilities.trigger_safe {
14430            return Ok(&entry.declared_schema);
14431        }
14432        return Err(MongrelError::InvalidArgument(format!(
14433            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
14434            entry.module
14435        )));
14436    }
14437    Err(MongrelError::InvalidArgument(format!(
14438        "trigger references unknown table {table:?}"
14439    )))
14440}
14441
14442fn validate_trigger_condition(
14443    condition: &TriggerCondition,
14444    schema: &Schema,
14445    target_schema: &Schema,
14446    event: TriggerEvent,
14447) -> Result<()> {
14448    match condition {
14449        TriggerCondition::Pk { value } => {
14450            if schema.primary_key().is_none() {
14451                return Err(MongrelError::InvalidArgument(
14452                    "trigger condition Pk references a table without a primary key".into(),
14453                ));
14454            }
14455            validate_trigger_value(value, target_schema, event)
14456        }
14457        TriggerCondition::Eq { column_id, value }
14458        | TriggerCondition::NotEq { column_id, value }
14459        | TriggerCondition::Lt { column_id, value }
14460        | TriggerCondition::Lte { column_id, value }
14461        | TriggerCondition::Gt { column_id, value }
14462        | TriggerCondition::Gte { column_id, value } => {
14463            validate_column_id(*column_id, schema)?;
14464            validate_trigger_value(value, target_schema, event)
14465        }
14466        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
14467            validate_column_id(*column_id, schema)
14468        }
14469        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
14470            validate_trigger_condition(left, schema, target_schema, event)?;
14471            validate_trigger_condition(right, schema, target_schema, event)
14472        }
14473        TriggerCondition::Not(condition) => {
14474            validate_trigger_condition(condition, schema, target_schema, event)
14475        }
14476    }
14477}
14478
14479fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
14480    match expr {
14481        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
14482            validate_trigger_value(value, schema, event)
14483        }
14484        TriggerExpr::Eq { left, right }
14485        | TriggerExpr::NotEq { left, right }
14486        | TriggerExpr::Lt { left, right }
14487        | TriggerExpr::Lte { left, right }
14488        | TriggerExpr::Gt { left, right }
14489        | TriggerExpr::Gte { left, right } => {
14490            validate_trigger_value(left, schema, event)?;
14491            validate_trigger_value(right, schema, event)
14492        }
14493        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
14494            validate_trigger_expr(left, schema, event)?;
14495            validate_trigger_expr(right, schema, event)
14496        }
14497        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
14498    }
14499}
14500
14501fn validate_trigger_value(
14502    value: &TriggerValue,
14503    schema: &Schema,
14504    event: TriggerEvent,
14505) -> Result<()> {
14506    match value {
14507        TriggerValue::Literal(_) => Ok(()),
14508        TriggerValue::NewColumn(id) => {
14509            if event == TriggerEvent::Delete {
14510                return Err(MongrelError::InvalidArgument(
14511                    "DELETE triggers cannot reference NEW".into(),
14512                ));
14513            }
14514            validate_column_id(*id, schema)
14515        }
14516        TriggerValue::OldColumn(id) => {
14517            if event == TriggerEvent::Insert {
14518                return Err(MongrelError::InvalidArgument(
14519                    "INSERT triggers cannot reference OLD".into(),
14520                ));
14521            }
14522            validate_column_id(*id, schema)
14523        }
14524        // SELECTED column references are only meaningful inside a foreach loop.
14525        // Strict loop-scope validation is deferred to runtime; the executor raises
14526        // an error if a selected row is not available.
14527        TriggerValue::SelectedColumn(_) => Ok(()),
14528    }
14529}
14530
14531/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
14532/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
14533/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
14534/// catalog. This pass closes that window by reconstructing missing entries
14535/// (and marking committed drops) before tables are mounted.
14536fn recover_ddl_from_wal(
14537    root: &Path,
14538    durable_root: Option<&crate::durable_file::DurableRoot>,
14539    target_catalog: &mut Catalog,
14540    meta_dek: Option<&[u8; META_DEK_LEN]>,
14541    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
14542    apply: bool,
14543    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14544) -> Result<()> {
14545    use crate::wal::SharedWal;
14546    let records = match durable_root {
14547        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
14548        None => SharedWal::replay_with_dek(root, wal_dek)?,
14549    };
14550    recover_ddl_from_records(
14551        root,
14552        durable_root,
14553        target_catalog,
14554        meta_dek,
14555        apply,
14556        table_roots,
14557        &records,
14558    )
14559}
14560
14561fn recover_ddl_from_records(
14562    root: &Path,
14563    durable_root: Option<&crate::durable_file::DurableRoot>,
14564    target_catalog: &mut Catalog,
14565    meta_dek: Option<&[u8; META_DEK_LEN]>,
14566    apply: bool,
14567    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14568    records: &[crate::wal::Record],
14569) -> Result<()> {
14570    use crate::wal::{DdlOp, Op};
14571
14572    let original_catalog = target_catalog.clone();
14573    let mut recovered_catalog = original_catalog.clone();
14574    let cat = &mut recovered_catalog;
14575    let mut created_table_ids = HashSet::<u64>::new();
14576    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
14577
14578    let mut committed: HashMap<u64, u64> = HashMap::new();
14579    for r in records {
14580        if let Op::TxnCommit { epoch: ce, .. } = r.op {
14581            committed.insert(r.txn_id, ce);
14582        }
14583    }
14584    let catalog_snapshot_txns = records
14585        .iter()
14586        .filter_map(|record| {
14587            (committed.contains_key(&record.txn_id)
14588                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
14589            .then_some(record.txn_id)
14590        })
14591        .collect::<HashSet<_>>();
14592
14593    let mut changed = false;
14594    let mut applied_catalog_epoch = cat.db_epoch;
14595    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
14596    for r in records.iter().cloned() {
14597        let Some(&ce) = committed.get(&r.txn_id) else {
14598            continue;
14599        };
14600        let txn_id = r.txn_id;
14601        match r.op {
14602            Op::Ddl(DdlOp::CreateTable {
14603                table_id,
14604                ref name,
14605                ref schema_json,
14606            }) => {
14607                if cat.tables.iter().any(|t| t.table_id == table_id) {
14608                    continue;
14609                }
14610                let schema = DdlOp::decode_schema(schema_json)?;
14611                validate_recovered_schema(&schema)?;
14612                created_table_ids.insert(table_id);
14613                cat.tables.push(CatalogEntry {
14614                    table_id,
14615                    name: name.clone(),
14616                    schema,
14617                    state: TableState::Live,
14618                    created_epoch: ce,
14619                });
14620                cat.next_table_id =
14621                    cat.next_table_id
14622                        .max(table_id.checked_add(1).ok_or_else(|| {
14623                            MongrelError::Full("table id namespace exhausted".into())
14624                        })?);
14625                changed = true;
14626            }
14627            Op::Ddl(DdlOp::CreateBuildingTable {
14628                table_id,
14629                ref build_name,
14630                ref intended_name,
14631                ref query_id,
14632                created_at_unix_nanos,
14633                ref schema_json,
14634            }) => {
14635                if cat.tables.iter().any(|table| table.table_id == table_id) {
14636                    continue;
14637                }
14638                let schema = DdlOp::decode_schema(schema_json)?;
14639                validate_recovered_schema(&schema)?;
14640                created_table_ids.insert(table_id);
14641                cat.tables.push(CatalogEntry {
14642                    table_id,
14643                    name: build_name.clone(),
14644                    schema,
14645                    state: TableState::Building {
14646                        intended_name: intended_name.clone(),
14647                        query_id: query_id.clone(),
14648                        created_at_unix_nanos,
14649                        replaces_table_id: None,
14650                    },
14651                    created_epoch: ce,
14652                });
14653                cat.next_table_id =
14654                    cat.next_table_id
14655                        .max(table_id.checked_add(1).ok_or_else(|| {
14656                            MongrelError::Full("table id namespace exhausted".into())
14657                        })?);
14658                changed = true;
14659            }
14660            Op::Ddl(DdlOp::CreateRebuildingTable {
14661                table_id,
14662                ref build_name,
14663                ref intended_name,
14664                ref query_id,
14665                created_at_unix_nanos,
14666                replaces_table_id,
14667                ref schema_json,
14668            }) => {
14669                if cat.tables.iter().any(|table| table.table_id == table_id) {
14670                    continue;
14671                }
14672                let schema = DdlOp::decode_schema(schema_json)?;
14673                validate_recovered_schema(&schema)?;
14674                created_table_ids.insert(table_id);
14675                cat.tables.push(CatalogEntry {
14676                    table_id,
14677                    name: build_name.clone(),
14678                    schema,
14679                    state: TableState::Building {
14680                        intended_name: intended_name.clone(),
14681                        query_id: query_id.clone(),
14682                        created_at_unix_nanos,
14683                        replaces_table_id: Some(replaces_table_id),
14684                    },
14685                    created_epoch: ce,
14686                });
14687                cat.next_table_id =
14688                    cat.next_table_id
14689                        .max(table_id.checked_add(1).ok_or_else(|| {
14690                            MongrelError::Full("table id namespace exhausted".into())
14691                        })?);
14692                changed = true;
14693            }
14694            Op::Ddl(DdlOp::DropTable { table_id }) => {
14695                let mut dropped_name = None;
14696                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14697                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
14698                        dropped_name = Some(entry.name.clone());
14699                        entry.state = TableState::Dropped { at_epoch: ce };
14700                        changed = true;
14701                    }
14702                }
14703                if let Some(name) = dropped_name {
14704                    let before = cat.materialized_views.len();
14705                    cat.materialized_views
14706                        .retain(|definition| definition.name != name);
14707                    changed |= before != cat.materialized_views.len();
14708                    cat.security.rls_tables.retain(|table| table != &name);
14709                    cat.security.policies.retain(|policy| policy.table != name);
14710                    cat.security.masks.retain(|mask| mask.table != name);
14711                    for role in &mut cat.roles {
14712                        role.permissions
14713                            .retain(|permission| permission_table(permission) != Some(&name));
14714                    }
14715                    if !catalog_snapshot_txns.contains(&txn_id) {
14716                        advance_security_version(cat)?;
14717                    }
14718                }
14719            }
14720            Op::Ddl(DdlOp::PublishBuildingTable {
14721                table_id,
14722                ref new_name,
14723            }) => {
14724                if let Some(entry) = cat
14725                    .tables
14726                    .iter_mut()
14727                    .find(|table| table.table_id == table_id)
14728                {
14729                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
14730                        entry.name = new_name.clone();
14731                        entry.state = TableState::Live;
14732                        changed = true;
14733                    }
14734                }
14735            }
14736            Op::Ddl(DdlOp::ReplaceBuildingTable {
14737                table_id,
14738                replaced_table_id,
14739                ref new_name,
14740            }) => {
14741                changed |=
14742                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
14743            }
14744            Op::Ddl(DdlOp::RenameTable {
14745                table_id,
14746                ref new_name,
14747            }) => {
14748                let mut old_name = None;
14749                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14750                    if entry.name != *new_name {
14751                        old_name = Some(entry.name.clone());
14752                        entry.name = new_name.clone();
14753                        changed = true;
14754                    }
14755                }
14756                if let Some(old_name) = old_name {
14757                    if let Some(definition) = cat
14758                        .materialized_views
14759                        .iter_mut()
14760                        .find(|definition| definition.name == old_name)
14761                    {
14762                        definition.name = new_name.clone();
14763                    }
14764                    for table in &mut cat.security.rls_tables {
14765                        if *table == old_name {
14766                            *table = new_name.clone();
14767                        }
14768                    }
14769                    for policy in &mut cat.security.policies {
14770                        if policy.table == old_name {
14771                            policy.table = new_name.clone();
14772                        }
14773                    }
14774                    for mask in &mut cat.security.masks {
14775                        if mask.table == old_name {
14776                            mask.table = new_name.clone();
14777                        }
14778                    }
14779                    for role in &mut cat.roles {
14780                        for permission in &mut role.permissions {
14781                            rename_permission_table(permission, &old_name, new_name);
14782                        }
14783                    }
14784                    if !catalog_snapshot_txns.contains(&txn_id) {
14785                        advance_security_version(cat)?;
14786                    }
14787                }
14788                // If the entry is absent, its CreateTable was already
14789                // checkpointed carrying the post-rename name, so there is
14790                // nothing to apply — a no-op, not an error.
14791            }
14792            Op::Ddl(DdlOp::AlterTable {
14793                table_id,
14794                ref column_json,
14795            }) => {
14796                let column = DdlOp::decode_column(column_json)?;
14797                let mut renamed = None;
14798                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14799                    renamed = entry
14800                        .schema
14801                        .columns
14802                        .iter()
14803                        .find(|existing| existing.id == column.id && existing.name != column.name)
14804                        .map(|existing| {
14805                            (
14806                                entry.name.clone(),
14807                                existing.name.clone(),
14808                                column.name.clone(),
14809                            )
14810                        });
14811                    if apply_recovered_column_def(&mut entry.schema, column)? {
14812                        validate_recovered_schema(&entry.schema)?;
14813                        changed = true;
14814                    }
14815                }
14816                if let Some((table, old_name, new_name)) = renamed {
14817                    for role in &mut cat.roles {
14818                        for permission in &mut role.permissions {
14819                            rename_permission_column(permission, &table, &old_name, &new_name);
14820                        }
14821                    }
14822                    if !catalog_snapshot_txns.contains(&txn_id) {
14823                        advance_security_version(cat)?;
14824                    }
14825                }
14826            }
14827            Op::Ddl(DdlOp::SetTtl {
14828                table_id,
14829                ref policy_json,
14830            }) => {
14831                let policy = DdlOp::decode_ttl(policy_json)?;
14832                let entry = cat
14833                    .tables
14834                    .iter()
14835                    .find(|entry| entry.table_id == table_id)
14836                    .ok_or_else(|| {
14837                        MongrelError::Schema(format!(
14838                            "recovered TTL references unknown table id {table_id}"
14839                        ))
14840                    })?;
14841                if let Some(policy) = policy {
14842                    let valid = entry
14843                        .schema
14844                        .columns
14845                        .iter()
14846                        .find(|column| column.id == policy.column_id)
14847                        .is_some_and(|column| {
14848                            column.ty == TypeId::TimestampNanos
14849                                && policy.duration_nanos > 0
14850                                && policy.duration_nanos <= i64::MAX as u64
14851                        });
14852                    if !valid {
14853                        return Err(MongrelError::Schema(format!(
14854                            "invalid recovered TTL policy for table id {table_id}"
14855                        )));
14856                    }
14857                }
14858                ttl_updates.insert(table_id, (policy, ce));
14859            }
14860            Op::Ddl(DdlOp::SetMaterializedView {
14861                ref name,
14862                ref definition_json,
14863            }) => {
14864                let definition = DdlOp::decode_materialized_view(definition_json)?;
14865                if definition.name != *name {
14866                    return Err(MongrelError::Schema(format!(
14867                        "materialized view WAL name mismatch: {name:?}"
14868                    )));
14869                }
14870                if cat.live(name).is_some() {
14871                    if let Some(existing) = cat
14872                        .materialized_views
14873                        .iter_mut()
14874                        .find(|existing| existing.name == *name)
14875                    {
14876                        if *existing != definition {
14877                            *existing = definition;
14878                            changed = true;
14879                        }
14880                    } else {
14881                        cat.materialized_views.push(definition);
14882                        changed = true;
14883                    }
14884                }
14885            }
14886            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
14887                let security = DdlOp::decode_security(security_json)?;
14888                validate_security_catalog(cat, &security)?;
14889                if cat.security != security {
14890                    cat.security = security;
14891                    if !catalog_snapshot_txns.contains(&txn_id) {
14892                        advance_security_version(cat)?;
14893                    }
14894                    changed = true;
14895                }
14896            }
14897            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
14898                let target = match key.as_str() {
14899                    "user_version" => &mut cat.user_version,
14900                    "application_id" => &mut cat.application_id,
14901                    _ => {
14902                        return Err(MongrelError::InvalidArgument(format!(
14903                            "unsupported recovered SQL pragma {key:?}"
14904                        )))
14905                    }
14906                };
14907                if *target != Some(value) {
14908                    *target = Some(value);
14909                    cat.db_epoch = cat.db_epoch.max(ce);
14910                    changed = true;
14911                }
14912            }
14913            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
14914                if ce <= applied_catalog_epoch {
14915                    continue;
14916                }
14917                let snapshot = DdlOp::decode_catalog(catalog_json)?;
14918                if snapshot.db_epoch != ce {
14919                    return Err(MongrelError::Schema(format!(
14920                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
14921                        snapshot.db_epoch
14922                    )));
14923                }
14924                validate_recovered_catalog(&snapshot)?;
14925                validate_catalog_transition(cat, &snapshot)?;
14926                *cat = snapshot;
14927                applied_catalog_epoch = ce;
14928                changed = true;
14929            }
14930            _ => {}
14931        }
14932    }
14933
14934    if cat.db_epoch < max_committed_epoch {
14935        cat.db_epoch = max_committed_epoch;
14936        changed = true;
14937    }
14938    changed |= repair_catalog_allocator_counters(cat)?;
14939
14940    validate_recovered_catalog(cat)?;
14941    let storage_reconciliation = validate_recovered_storage_plan(
14942        root,
14943        durable_root,
14944        cat,
14945        &created_table_ids,
14946        &ttl_updates,
14947        meta_dek,
14948    )?;
14949
14950    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
14951    if apply && (changed || needs_storage_apply) {
14952        for table_id in storage_reconciliation {
14953            let entry = cat
14954                .tables
14955                .iter()
14956                .find(|entry| entry.table_id == table_id)
14957                .ok_or_else(|| MongrelError::CorruptWal {
14958                    offset: table_id,
14959                    reason: "recovery storage plan lost its catalog table".into(),
14960                })?;
14961            ensure_recovered_table_storage(
14962                table_roots
14963                    .and_then(|roots| roots.get(&table_id))
14964                    .map(Arc::as_ref),
14965                durable_root,
14966                &root.join(TABLES_DIR).join(table_id.to_string()),
14967                table_id,
14968                &entry.schema,
14969                meta_dek,
14970            )?;
14971        }
14972        for (table_id, (policy, ttl_epoch)) in ttl_updates {
14973            let Some(entry) = cat.tables.iter().find(|entry| {
14974                entry.table_id == table_id
14975                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
14976            }) else {
14977                continue;
14978            };
14979            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
14980            {
14981                root.try_clone()?
14982            } else if let Some(root) = durable_root {
14983                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
14984            } else {
14985                crate::durable_file::DurableRoot::open(
14986                    root.join(TABLES_DIR).join(table_id.to_string()),
14987                )?
14988            };
14989            let table_dir = table_root.io_path()?;
14990            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
14991            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
14992                manifest.ttl = policy;
14993                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
14994                manifest.schema_id = entry.schema.schema_id;
14995                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14996            }
14997        }
14998        if changed {
14999            match durable_root {
15000                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
15001                None => catalog::write_atomic(root, cat, meta_dek)?,
15002            }
15003        }
15004    }
15005    *target_catalog = recovered_catalog;
15006    Ok(())
15007}
15008
15009fn ensure_recovered_table_storage(
15010    pinned_table: Option<&crate::durable_file::DurableRoot>,
15011    durable_root: Option<&crate::durable_file::DurableRoot>,
15012    fallback_table_dir: &Path,
15013    table_id: u64,
15014    schema: &Schema,
15015    meta_dek: Option<&[u8; META_DEK_LEN]>,
15016) -> Result<()> {
15017    let table_root = if let Some(root) = pinned_table {
15018        root.try_clone()?
15019    } else if let Some(root) = durable_root {
15020        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
15021        match root.open_directory(&relative) {
15022            Ok(table) => table,
15023            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
15024                root.create_directory_all_pinned(relative)?
15025            }
15026            Err(error) => return Err(error.into()),
15027        }
15028    } else {
15029        crate::durable_file::create_directory_all(fallback_table_dir)?;
15030        crate::durable_file::DurableRoot::open(fallback_table_dir)?
15031    };
15032    let table_dir = table_root.io_path()?;
15033    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
15034        Ok(manifest) => {
15035            if manifest.table_id != table_id {
15036                return Err(MongrelError::Conflict(format!(
15037                    "recovered table directory id mismatch: expected {table_id}, found {}",
15038                    manifest.table_id
15039                )));
15040            }
15041            Some(manifest)
15042        }
15043        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
15044        Err(error) => return Err(error),
15045    };
15046
15047    table_root.create_directory_all(crate::engine::WAL_DIR)?;
15048    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
15049    crate::engine::write_schema(&table_dir, schema)?;
15050
15051    if let Some(mut manifest) = existing_manifest.take() {
15052        if manifest.schema_id != schema.schema_id {
15053            manifest.schema_id = schema.schema_id;
15054            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
15055        }
15056    } else {
15057        // The DB-wide meta DEK is also the per-table manifest meta DEK.
15058        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
15059        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
15060    }
15061    Ok(())
15062}
15063
15064fn validate_recovered_schema(schema: &Schema) -> Result<()> {
15065    schema.validate_auto_increment()?;
15066    schema.validate_defaults()?;
15067    schema.validate_ai()?;
15068    let mut column_ids = HashSet::new();
15069    let mut column_names = HashSet::new();
15070    for column in &schema.columns {
15071        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
15072            return Err(MongrelError::Schema(
15073                "recovered schema contains duplicate columns".into(),
15074            ));
15075        }
15076        match &column.ty {
15077            TypeId::Decimal128 { precision, scale }
15078                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
15079            {
15080                return Err(MongrelError::Schema(format!(
15081                    "column {:?} has invalid decimal precision or scale",
15082                    column.name
15083                )));
15084            }
15085            TypeId::Enum { variants }
15086                if variants.is_empty()
15087                    || variants.iter().any(String::is_empty)
15088                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
15089            {
15090                return Err(MongrelError::Schema(format!(
15091                    "column {:?} has invalid enum variants",
15092                    column.name
15093                )));
15094            }
15095            _ => {}
15096        }
15097    }
15098    let mut index_names = HashSet::new();
15099    for index in &schema.indexes {
15100        index.validate_options()?;
15101        if index.name.is_empty()
15102            || !index_names.insert(index.name.as_str())
15103            || schema
15104                .columns
15105                .iter()
15106                .all(|column| column.id != index.column_id)
15107        {
15108            return Err(MongrelError::Schema(format!(
15109                "recovered index {:?} references missing column {}",
15110                index.name, index.column_id
15111            )));
15112        }
15113    }
15114    let mut colocated = HashSet::new();
15115    for group in &schema.colocation {
15116        if group.is_empty()
15117            || group.iter().any(|id| !column_ids.contains(id))
15118            || group.iter().any(|id| !colocated.insert(*id))
15119        {
15120            return Err(MongrelError::Schema(
15121                "recovered schema contains invalid column co-location groups".into(),
15122            ));
15123        }
15124    }
15125
15126    let mut constraint_ids = HashSet::new();
15127    let mut constraint_names = HashSet::<String>::new();
15128    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
15129        if name.is_empty()
15130            || !constraint_ids.insert(id)
15131            || !constraint_names.insert(name.to_owned())
15132        {
15133            return Err(MongrelError::Schema(
15134                "recovered schema contains duplicate or empty constraint identities".into(),
15135            ));
15136        }
15137        Ok(())
15138    };
15139    for unique in &schema.constraints.uniques {
15140        validate_constraint_identity(unique.id, &unique.name)?;
15141        if unique.columns.is_empty()
15142            || unique.columns.iter().any(|id| !column_ids.contains(id))
15143            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
15144        {
15145            return Err(MongrelError::Schema(format!(
15146                "unique constraint {:?} has invalid columns",
15147                unique.name
15148            )));
15149        }
15150    }
15151    for foreign_key in &schema.constraints.foreign_keys {
15152        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
15153        if foreign_key.ref_table.is_empty()
15154            || foreign_key.columns.is_empty()
15155            || foreign_key.columns.len() != foreign_key.ref_columns.len()
15156            || foreign_key
15157                .columns
15158                .iter()
15159                .any(|id| !column_ids.contains(id))
15160            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
15161            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
15162                != foreign_key.ref_columns.len()
15163        {
15164            return Err(MongrelError::Schema(format!(
15165                "foreign key {:?} has invalid columns",
15166                foreign_key.name
15167            )));
15168        }
15169        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
15170            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
15171            && foreign_key.columns.iter().any(|id| {
15172                schema
15173                    .columns
15174                    .iter()
15175                    .find(|column| column.id == *id)
15176                    .is_none_or(|column| {
15177                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
15178                    })
15179            })
15180        {
15181            return Err(MongrelError::Schema(format!(
15182                "foreign key {:?} uses SET NULL on a non-nullable column",
15183                foreign_key.name
15184            )));
15185        }
15186    }
15187    for check in &schema.constraints.checks {
15188        validate_constraint_identity(check.id, &check.name)?;
15189        check.expr.validate()?;
15190        validate_check_columns(&check.expr, &column_ids)?;
15191    }
15192    Ok(())
15193}
15194
15195fn validate_check_columns(
15196    expression: &crate::constraint::CheckExpr,
15197    column_ids: &HashSet<u16>,
15198) -> Result<()> {
15199    use crate::constraint::CheckExpr;
15200    match expression {
15201        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
15202            if column_ids.contains(id) {
15203                Ok(())
15204            } else {
15205                Err(MongrelError::Schema(format!(
15206                    "check constraint references unknown column {id}"
15207                )))
15208            }
15209        }
15210        CheckExpr::Regex { col, .. } => {
15211            if column_ids.contains(col) {
15212                Ok(())
15213            } else {
15214                Err(MongrelError::Schema(format!(
15215                    "check constraint references unknown column {col}"
15216                )))
15217            }
15218        }
15219        CheckExpr::Add(left, right)
15220        | CheckExpr::Sub(left, right)
15221        | CheckExpr::Mul(left, right)
15222        | CheckExpr::Div(left, right)
15223        | CheckExpr::Mod(left, right)
15224        | CheckExpr::Eq(left, right)
15225        | CheckExpr::Ne(left, right)
15226        | CheckExpr::Lt(left, right)
15227        | CheckExpr::Le(left, right)
15228        | CheckExpr::Gt(left, right)
15229        | CheckExpr::Ge(left, right)
15230        | CheckExpr::And(left, right)
15231        | CheckExpr::Or(left, right) => {
15232            validate_check_columns(left, column_ids)?;
15233            validate_check_columns(right, column_ids)
15234        }
15235        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
15236        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
15237    }
15238}
15239
15240fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
15241    for (name, prior, candidate) in [
15242        ("db_epoch", current.db_epoch, next.db_epoch),
15243        ("next_table_id", current.next_table_id, next.next_table_id),
15244        (
15245            "next_segment_no",
15246            current.next_segment_no,
15247            next.next_segment_no,
15248        ),
15249        ("next_user_id", current.next_user_id, next.next_user_id),
15250        (
15251            "security_version",
15252            current.security_version,
15253            next.security_version,
15254        ),
15255    ] {
15256        if candidate < prior {
15257            return Err(MongrelError::Schema(format!(
15258                "catalog snapshot rolls back {name} from {prior} to {candidate}"
15259            )));
15260        }
15261    }
15262    for prior in &current.tables {
15263        let Some(candidate) = next
15264            .tables
15265            .iter()
15266            .find(|entry| entry.table_id == prior.table_id)
15267        else {
15268            return Err(MongrelError::Schema(format!(
15269                "catalog snapshot removes table identity {}",
15270                prior.table_id
15271            )));
15272        };
15273        if candidate.created_epoch != prior.created_epoch
15274            || candidate.schema.schema_id < prior.schema.schema_id
15275            || matches!(prior.state, TableState::Dropped { .. })
15276                && !matches!(candidate.state, TableState::Dropped { .. })
15277        {
15278            return Err(MongrelError::Schema(format!(
15279                "catalog snapshot rolls back table identity {}",
15280                prior.table_id
15281            )));
15282        }
15283    }
15284    for prior in &current.users {
15285        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
15286            if candidate.username != prior.username
15287                || candidate.created_epoch != prior.created_epoch
15288            {
15289                return Err(MongrelError::Schema(format!(
15290                    "catalog snapshot reuses user identity {}",
15291                    prior.id
15292                )));
15293            }
15294        }
15295    }
15296    Ok(())
15297}
15298
15299fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
15300    let mut table_ids = HashSet::new();
15301    let mut active_names = HashSet::new();
15302    let mut max_table_id = None::<u64>;
15303    for entry in &catalog.tables {
15304        if !table_ids.insert(entry.table_id) {
15305            return Err(MongrelError::Schema(format!(
15306                "catalog contains duplicate table id {}",
15307                entry.table_id
15308            )));
15309        }
15310        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
15311        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
15312            return Err(MongrelError::Schema(format!(
15313                "catalog table {} has invalid name or creation epoch",
15314                entry.table_id
15315            )));
15316        }
15317        validate_recovered_schema(&entry.schema)?;
15318        match &entry.state {
15319            TableState::Live => {
15320                if !active_names.insert(entry.name.as_str()) {
15321                    return Err(MongrelError::Schema(format!(
15322                        "catalog contains duplicate active table name {:?}",
15323                        entry.name
15324                    )));
15325                }
15326            }
15327            TableState::Dropped { at_epoch } => {
15328                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
15329                    return Err(MongrelError::Schema(format!(
15330                        "catalog table {} has invalid drop epoch {at_epoch}",
15331                        entry.table_id
15332                    )));
15333                }
15334            }
15335            TableState::Building {
15336                intended_name,
15337                query_id,
15338                replaces_table_id,
15339                ..
15340            } => {
15341                if intended_name.is_empty() || query_id.is_empty() {
15342                    return Err(MongrelError::Schema(format!(
15343                        "building table {} has empty identity fields",
15344                        entry.table_id
15345                    )));
15346                }
15347                if !active_names.insert(entry.name.as_str()) {
15348                    return Err(MongrelError::Schema(format!(
15349                        "catalog contains duplicate active/building table name {:?}",
15350                        entry.name
15351                    )));
15352                }
15353                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
15354                    return Err(MongrelError::Schema(
15355                        "building table cannot replace itself".into(),
15356                    ));
15357                }
15358            }
15359        }
15360    }
15361    if let Some(maximum) = max_table_id {
15362        let required = maximum
15363            .checked_add(1)
15364            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15365        if catalog.next_table_id < required {
15366            return Err(MongrelError::Schema(format!(
15367                "catalog next_table_id {} precedes required {required}",
15368                catalog.next_table_id
15369            )));
15370        }
15371    }
15372    for entry in &catalog.tables {
15373        if let TableState::Building {
15374            replaces_table_id: Some(replaced),
15375            ..
15376        } = entry.state
15377        {
15378            if !table_ids.contains(&replaced) {
15379                return Err(MongrelError::Schema(format!(
15380                    "building table {} replaces unknown table {replaced}",
15381                    entry.table_id
15382                )));
15383            }
15384        }
15385    }
15386    for entry in &catalog.tables {
15387        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15388            validate_foreign_key_targets(catalog, &entry.schema)?;
15389        }
15390    }
15391
15392    let mut external_names = HashSet::new();
15393    for entry in &catalog.external_tables {
15394        entry.validate()?;
15395        validate_recovered_schema(&entry.declared_schema)?;
15396        if !entry.declared_schema.constraints.is_empty() {
15397            return Err(MongrelError::Schema(format!(
15398                "external table {:?} cannot carry engine-enforced constraints",
15399                entry.name
15400            )));
15401        }
15402        if entry.created_epoch > catalog.db_epoch
15403            || !external_names.insert(entry.name.as_str())
15404            || active_names.contains(entry.name.as_str())
15405        {
15406            return Err(MongrelError::Schema(format!(
15407                "invalid or duplicate external table {:?}",
15408                entry.name
15409            )));
15410        }
15411    }
15412
15413    let mut procedure_names = HashSet::new();
15414    for entry in &catalog.procedures {
15415        entry.procedure.validate()?;
15416        if entry.procedure.created_epoch > entry.procedure.updated_epoch
15417            || entry.procedure.updated_epoch > catalog.db_epoch
15418            || !procedure_names.insert(entry.procedure.name.as_str())
15419        {
15420            return Err(MongrelError::Schema(format!(
15421                "invalid or duplicate procedure {:?}",
15422                entry.procedure.name
15423            )));
15424        }
15425        validate_recovered_procedure_references(catalog, &entry.procedure)?;
15426    }
15427
15428    let mut trigger_names = HashSet::new();
15429    for entry in &catalog.triggers {
15430        entry.trigger.validate()?;
15431        if entry.trigger.created_epoch > entry.trigger.updated_epoch
15432            || entry.trigger.updated_epoch > catalog.db_epoch
15433            || !trigger_names.insert(entry.trigger.name.as_str())
15434        {
15435            return Err(MongrelError::Schema(format!(
15436                "invalid or duplicate trigger {:?}",
15437                entry.trigger.name
15438            )));
15439        }
15440        validate_recovered_trigger_references(catalog, &entry.trigger)?;
15441    }
15442
15443    let mut views = HashSet::new();
15444    for view in &catalog.materialized_views {
15445        let target = catalog.live(&view.name).ok_or_else(|| {
15446            MongrelError::Schema(format!(
15447                "materialized view {:?} has no live table",
15448                view.name
15449            ))
15450        })?;
15451        if view.name.is_empty()
15452            || view.query.trim().is_empty()
15453            || view.last_refresh_epoch > catalog.db_epoch
15454            || !views.insert(view.name.as_str())
15455        {
15456            return Err(MongrelError::Schema(format!(
15457                "materialized view {:?} has no unique live table",
15458                view.name
15459            )));
15460        }
15461        if let Some(incremental) = &view.incremental {
15462            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
15463                MongrelError::Schema(format!(
15464                    "materialized view {:?} references missing source {:?}",
15465                    view.name, incremental.source_table
15466                ))
15467            })?;
15468            if source.table_id != incremental.source_table_id
15469                || source
15470                    .schema
15471                    .columns
15472                    .iter()
15473                    .all(|column| column.id != incremental.group_column)
15474            {
15475                return Err(MongrelError::Schema(format!(
15476                    "materialized view {:?} has invalid incremental source",
15477                    view.name
15478                )));
15479            }
15480            let target_ids = target
15481                .schema
15482                .columns
15483                .iter()
15484                .map(|column| column.id)
15485                .collect::<HashSet<_>>();
15486            let mut output_ids = HashSet::new();
15487            let count_outputs = incremental
15488                .outputs
15489                .iter()
15490                .filter(|output| {
15491                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15492                })
15493                .count();
15494            if incremental.checkpoint_event_id.is_empty()
15495                || !target_ids.contains(&incremental.group_output_column)
15496                || !target_ids.contains(&incremental.count_output_column)
15497                || incremental.outputs.is_empty()
15498                || count_outputs != 1
15499                || incremental.outputs.iter().any(|output| {
15500                    !target_ids.contains(&output.output_column)
15501                        || output.output_column == incremental.group_output_column
15502                        || !output_ids.insert(output.output_column)
15503                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15504                            && output.output_column != incremental.count_output_column
15505                        || match output.kind {
15506                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
15507                                source
15508                                    .schema
15509                                    .columns
15510                                    .iter()
15511                                    .all(|column| column.id != source_column)
15512                            }
15513                            crate::catalog::IncrementalAggregateKind::Count => false,
15514                        }
15515                })
15516            {
15517                return Err(MongrelError::Schema(format!(
15518                    "materialized view {:?} has invalid incremental outputs",
15519                    view.name
15520                )));
15521            }
15522        }
15523    }
15524
15525    validate_security_catalog(catalog, &catalog.security)?;
15526    validate_recovered_auth_catalog(catalog)?;
15527    Ok(())
15528}
15529
15530fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
15531    let mut changed = false;
15532    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
15533        let required = maximum
15534            .checked_add(1)
15535            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15536        if catalog.next_table_id < required {
15537            catalog.next_table_id = required;
15538            changed = true;
15539        }
15540    }
15541    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
15542        let required = maximum
15543            .checked_add(1)
15544            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
15545        if catalog.next_user_id < required {
15546            catalog.next_user_id = required;
15547            changed = true;
15548        }
15549    }
15550    Ok(changed)
15551}
15552
15553fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
15554    for foreign_key in &schema.constraints.foreign_keys {
15555        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
15556            MongrelError::Schema(format!(
15557                "foreign key {:?} references unknown live table {:?}",
15558                foreign_key.name, foreign_key.ref_table
15559            ))
15560        })?;
15561        let referenced_unique = parent
15562            .schema
15563            .constraints
15564            .uniques
15565            .iter()
15566            .any(|unique| unique.columns == foreign_key.ref_columns)
15567            || foreign_key.ref_columns.len() == 1
15568                && parent
15569                    .schema
15570                    .primary_key()
15571                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
15572        if !referenced_unique {
15573            return Err(MongrelError::Schema(format!(
15574                "foreign key {:?} does not reference a unique key",
15575                foreign_key.name
15576            )));
15577        }
15578        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
15579            let local = schema.columns.iter().find(|column| column.id == *local_id);
15580            let referenced = parent
15581                .schema
15582                .columns
15583                .iter()
15584                .find(|column| column.id == *parent_id);
15585            if local
15586                .zip(referenced)
15587                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
15588            {
15589                return Err(MongrelError::Schema(format!(
15590                    "foreign key {:?} has missing or incompatible columns",
15591                    foreign_key.name
15592                )));
15593            }
15594        }
15595    }
15596    Ok(())
15597}
15598
15599fn validate_recovered_procedure_references(
15600    catalog: &Catalog,
15601    procedure: &StoredProcedure,
15602) -> Result<()> {
15603    for step in &procedure.body.steps {
15604        let Some(table_name) = step.table() else {
15605            continue;
15606        };
15607        let schema = &catalog
15608            .live(table_name)
15609            .ok_or_else(|| {
15610                MongrelError::Schema(format!(
15611                    "procedure {:?} references unknown table {table_name:?}",
15612                    procedure.name
15613                ))
15614            })?
15615            .schema;
15616        match step {
15617            ProcedureStep::NativeQuery {
15618                conditions,
15619                projection,
15620                ..
15621            } => {
15622                for condition in conditions {
15623                    validate_condition_columns(condition, schema)?;
15624                }
15625                for id in projection.iter().flatten() {
15626                    validate_column_id(*id, schema)?;
15627                }
15628            }
15629            ProcedureStep::Put { cells, .. } => {
15630                for cell in cells {
15631                    validate_column_id(cell.column_id, schema)?;
15632                }
15633            }
15634            ProcedureStep::Upsert {
15635                cells,
15636                update_cells,
15637                ..
15638            } => {
15639                for cell in cells.iter().chain(update_cells.iter().flatten()) {
15640                    validate_column_id(cell.column_id, schema)?;
15641                }
15642            }
15643            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
15644                return Err(MongrelError::Schema(format!(
15645                    "procedure {:?} deletes by primary key on table without one",
15646                    procedure.name
15647                )));
15648            }
15649            ProcedureStep::DeleteByPk { .. }
15650            | ProcedureStep::DeleteRows { .. }
15651            | ProcedureStep::SqlQuery { .. } => {}
15652        }
15653    }
15654    Ok(())
15655}
15656
15657fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
15658    let target_schema = match &trigger.target {
15659        TriggerTarget::Table(name) => catalog
15660            .live(name)
15661            .ok_or_else(|| {
15662                MongrelError::Schema(format!(
15663                    "trigger {:?} references unknown table {name:?}",
15664                    trigger.name
15665                ))
15666            })?
15667            .schema
15668            .clone(),
15669        TriggerTarget::View(_) => Schema {
15670            columns: trigger.target_columns.clone(),
15671            ..Schema::default()
15672        },
15673    };
15674    for column in &trigger.update_of {
15675        if target_schema.column(column).is_none() {
15676            return Err(MongrelError::Schema(format!(
15677                "trigger {:?} references unknown UPDATE OF column {column:?}",
15678                trigger.name
15679            )));
15680        }
15681    }
15682    if let Some(expr) = &trigger.when {
15683        validate_trigger_expr(expr, &target_schema, trigger.event)?;
15684    }
15685    let mut selects = HashMap::new();
15686    for step in &trigger.program.steps {
15687        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
15688            return Err(MongrelError::Schema(
15689                "SetNew is only valid in BEFORE triggers".into(),
15690            ));
15691        }
15692        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
15693    }
15694    Ok(())
15695}
15696
15697fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
15698    let mut role_names = HashSet::new();
15699    for role in &catalog.roles {
15700        if role.name.is_empty()
15701            || role.created_epoch > catalog.db_epoch
15702            || !role_names.insert(role.name.as_str())
15703        {
15704            return Err(MongrelError::Schema(format!(
15705                "invalid or duplicate role {:?}",
15706                role.name
15707            )));
15708        }
15709        for permission in &role.permissions {
15710            if let Some(table) = permission_table(permission) {
15711                let schema = catalog
15712                    .live(table)
15713                    .map(|entry| &entry.schema)
15714                    .or_else(|| {
15715                        catalog
15716                            .external_tables
15717                            .iter()
15718                            .find(|entry| entry.name == table)
15719                            .map(|entry| &entry.declared_schema)
15720                    })
15721                    .ok_or_else(|| {
15722                        MongrelError::Schema(format!(
15723                            "role {:?} references unknown table {table:?}",
15724                            role.name
15725                        ))
15726                    })?;
15727                let columns = match permission {
15728                    crate::auth::Permission::SelectColumns { columns, .. }
15729                    | crate::auth::Permission::InsertColumns { columns, .. }
15730                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
15731                    _ => None,
15732                };
15733                if columns.is_some_and(|columns| {
15734                    columns.is_empty()
15735                        || columns.iter().any(|column| schema.column(column).is_none())
15736                }) {
15737                    return Err(MongrelError::Schema(format!(
15738                        "role {:?} contains invalid column permissions",
15739                        role.name
15740                    )));
15741                }
15742            }
15743        }
15744    }
15745    let mut user_ids = HashSet::new();
15746    let mut usernames = HashSet::new();
15747    let mut maximum_user_id = 0;
15748    for user in &catalog.users {
15749        maximum_user_id = maximum_user_id.max(user.id);
15750        if user.id == 0
15751            || user.username.is_empty()
15752            || user.password_hash.is_empty()
15753            || user.created_epoch > catalog.db_epoch
15754            || !user_ids.insert(user.id)
15755            || !usernames.insert(user.username.as_str())
15756            || user
15757                .roles
15758                .iter()
15759                .any(|role| !role_names.contains(role.as_str()))
15760        {
15761            return Err(MongrelError::Schema(format!(
15762                "invalid or duplicate user {:?}",
15763                user.username
15764            )));
15765        }
15766    }
15767    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
15768        return Err(MongrelError::Schema(
15769            "catalog next_user_id does not advance beyond existing user ids".into(),
15770        ));
15771    }
15772    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
15773        return Err(MongrelError::Schema(
15774            "authenticated catalog has no administrator".into(),
15775        ));
15776    }
15777    Ok(())
15778}
15779
15780fn validate_recovered_storage_plan(
15781    root: &Path,
15782    durable_root: Option<&crate::durable_file::DurableRoot>,
15783    catalog: &Catalog,
15784    created_table_ids: &HashSet<u64>,
15785    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
15786    meta_dek: Option<&[u8; META_DEK_LEN]>,
15787) -> Result<Vec<u64>> {
15788    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
15789    let mut reconcile = Vec::new();
15790    for entry in &catalog.tables {
15791        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15792            continue;
15793        }
15794        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15795        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
15796        let table_exists = match durable_root {
15797            Some(root) => match root.open_directory(&relative_dir) {
15798                Ok(_) => true,
15799                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15800                Err(error) => return Err(error.into()),
15801            },
15802            None => table_dir.is_dir(),
15803        };
15804        if !table_exists {
15805            if created_table_ids.contains(&entry.table_id) {
15806                reconcile.push(entry.table_id);
15807                continue;
15808            }
15809            return Err(MongrelError::NotFound(format!(
15810                "catalog table {} storage is missing",
15811                entry.table_id
15812            )));
15813        }
15814        let manifest_result = match durable_root {
15815            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
15816            None => crate::manifest::read(&table_dir, meta_dek),
15817        };
15818        let manifest = match manifest_result {
15819            Ok(manifest) => manifest,
15820            Err(MongrelError::Io(error))
15821                if created_table_ids.contains(&entry.table_id)
15822                    && error.kind() == std::io::ErrorKind::NotFound =>
15823            {
15824                reconcile.push(entry.table_id);
15825                continue;
15826            }
15827            Err(error) => return Err(error),
15828        };
15829        if manifest.table_id != entry.table_id {
15830            return Err(MongrelError::Conflict(format!(
15831                "catalog table {} storage identity mismatch",
15832                entry.table_id
15833            )));
15834        }
15835        let schema_result = match durable_root {
15836            Some(root) => root
15837                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
15838                .map_err(MongrelError::from),
15839            None => crate::durable_file::open_regular_nofollow(
15840                &table_dir.join(crate::engine::SCHEMA_FILENAME),
15841            ),
15842        };
15843        let file = match schema_result {
15844            Ok(file) => file,
15845            Err(MongrelError::Io(error))
15846                if created_table_ids.contains(&entry.table_id)
15847                    && error.kind() == std::io::ErrorKind::NotFound =>
15848            {
15849                reconcile.push(entry.table_id);
15850                continue;
15851            }
15852            Err(error) => return Err(error),
15853        };
15854        let length = file.metadata()?.len();
15855        if length > MAX_SCHEMA_BYTES {
15856            return Err(MongrelError::ResourceLimitExceeded {
15857                resource: "recovered schema bytes",
15858                requested: usize::try_from(length).unwrap_or(usize::MAX),
15859                limit: MAX_SCHEMA_BYTES as usize,
15860            });
15861        }
15862        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
15863            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
15864        if manifest.schema_id != entry.schema.schema_id
15865            || crate::wal::DdlOp::encode_schema(&disk_schema)?
15866                != crate::wal::DdlOp::encode_schema(&entry.schema)?
15867        {
15868            reconcile.push(entry.table_id);
15869        }
15870    }
15871    for table_id in ttl_updates.keys() {
15872        if !catalog.tables.iter().any(|entry| {
15873            entry.table_id == *table_id
15874                && matches!(entry.state, TableState::Live | TableState::Building { .. })
15875        }) {
15876            continue;
15877        }
15878        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
15879        let table_exists = match durable_root {
15880            Some(root) => match root.open_directory(&relative_dir) {
15881                Ok(_) => true,
15882                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15883                Err(error) => return Err(error.into()),
15884            },
15885            None => root.join(&relative_dir).is_dir(),
15886        };
15887        if !table_exists && !created_table_ids.contains(table_id) {
15888            return Err(MongrelError::NotFound(format!(
15889                "TTL recovery table {table_id} storage is missing"
15890            )));
15891        }
15892    }
15893    reconcile.sort_unstable();
15894    reconcile.dedup();
15895    Ok(reconcile)
15896}
15897
15898fn validate_catalog_table_storage(
15899    root: &crate::durable_file::DurableRoot,
15900    catalog: &Catalog,
15901    meta_dek: Option<&[u8; META_DEK_LEN]>,
15902) -> Result<()> {
15903    for entry in &catalog.tables {
15904        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15905            continue;
15906        }
15907        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15908        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
15909        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
15910            return Err(MongrelError::Conflict(format!(
15911                "catalog table {} storage identity mismatch",
15912                entry.table_id
15913            )));
15914        }
15915        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
15916    }
15917    Ok(())
15918}
15919
15920fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
15921    match schema.columns.iter_mut().find(|c| c.id == column.id) {
15922        Some(existing) if *existing == column => Ok(false),
15923        Some(existing) => {
15924            *existing = column;
15925            schema.schema_id = schema
15926                .schema_id
15927                .checked_add(1)
15928                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15929            Ok(true)
15930        }
15931        None => {
15932            schema.columns.push(column);
15933            schema.schema_id = schema
15934                .schema_id
15935                .checked_add(1)
15936                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15937            Ok(true)
15938        }
15939    }
15940}
15941
15942fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
15943    use crate::auth::Permission;
15944    match permission {
15945        Permission::Select { table }
15946        | Permission::Insert { table }
15947        | Permission::Update { table }
15948        | Permission::Delete { table }
15949        | Permission::SelectColumns { table, .. }
15950        | Permission::InsertColumns { table, .. }
15951        | Permission::UpdateColumns { table, .. } => Some(table),
15952        Permission::All | Permission::Ddl | Permission::Admin => None,
15953    }
15954}
15955
15956fn apply_rebuilding_publish(
15957    catalog: &mut Catalog,
15958    table_id: u64,
15959    replaced_table_id: u64,
15960    new_name: &str,
15961    epoch: u64,
15962) -> Result<bool> {
15963    let already_published = catalog.tables.iter().any(|entry| {
15964        entry.table_id == table_id
15965            && entry.name == new_name
15966            && matches!(entry.state, TableState::Live)
15967    }) && catalog.tables.iter().any(|entry| {
15968        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
15969    });
15970    if already_published {
15971        return Ok(false);
15972    }
15973    let schema = catalog
15974        .tables
15975        .iter()
15976        .find(|entry| entry.table_id == table_id)
15977        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
15978        .schema
15979        .clone();
15980    let replaced = catalog
15981        .tables
15982        .iter_mut()
15983        .find(|entry| entry.table_id == replaced_table_id)
15984        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
15985    replaced.state = TableState::Dropped { at_epoch: epoch };
15986    let replacement = catalog
15987        .tables
15988        .iter_mut()
15989        .find(|entry| entry.table_id == table_id)
15990        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
15991    replacement.name = new_name.to_string();
15992    replacement.state = TableState::Live;
15993
15994    for role in &mut catalog.roles {
15995        role.permissions.retain_mut(|permission| {
15996            retain_rebuilt_permission_columns(permission, new_name, &schema)
15997        });
15998    }
15999    for definition in &mut catalog.materialized_views {
16000        if let Some(incremental) = definition.incremental.as_mut() {
16001            if incremental.source_table == new_name
16002                && incremental.source_table_id == replaced_table_id
16003            {
16004                incremental.source_table_id = table_id;
16005            }
16006        }
16007    }
16008    advance_security_version(catalog)?;
16009    Ok(true)
16010}
16011
16012fn retain_rebuilt_permission_columns(
16013    permission: &mut crate::auth::Permission,
16014    target_table: &str,
16015    schema: &Schema,
16016) -> bool {
16017    use crate::auth::Permission;
16018    let columns = match permission {
16019        Permission::SelectColumns { table, columns }
16020        | Permission::InsertColumns { table, columns }
16021        | Permission::UpdateColumns { table, columns }
16022            if table == target_table =>
16023        {
16024            Some(columns)
16025        }
16026        _ => None,
16027    };
16028    if let Some(columns) = columns {
16029        columns.retain(|column| schema.column(column).is_some());
16030        !columns.is_empty()
16031    } else {
16032        true
16033    }
16034}
16035
16036fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
16037    use crate::auth::Permission;
16038    let table = match permission {
16039        Permission::Select { table }
16040        | Permission::Insert { table }
16041        | Permission::Update { table }
16042        | Permission::Delete { table }
16043        | Permission::SelectColumns { table, .. }
16044        | Permission::InsertColumns { table, .. }
16045        | Permission::UpdateColumns { table, .. } => Some(table),
16046        Permission::All | Permission::Ddl | Permission::Admin => None,
16047    };
16048    if let Some(table) = table.filter(|table| table.as_str() == old) {
16049        *table = new.to_string();
16050    }
16051}
16052
16053fn rename_permission_column(
16054    permission: &mut crate::auth::Permission,
16055    target_table: &str,
16056    old: &str,
16057    new: &str,
16058) {
16059    use crate::auth::Permission;
16060    let columns = match permission {
16061        Permission::SelectColumns { table, columns }
16062        | Permission::InsertColumns { table, columns }
16063        | Permission::UpdateColumns { table, columns }
16064            if table == target_table =>
16065        {
16066            Some(columns)
16067        }
16068        _ => None,
16069    };
16070    if let Some(column) = columns
16071        .into_iter()
16072        .flatten()
16073        .find(|column| column.as_str() == old)
16074    {
16075        *column = new.to_string();
16076    }
16077}
16078
16079fn merge_permission(
16080    permissions: &mut Vec<crate::auth::Permission>,
16081    permission: crate::auth::Permission,
16082) {
16083    use crate::auth::Permission;
16084    let (kind, table, mut columns) = match permission {
16085        Permission::SelectColumns { table, columns } => (0, table, columns),
16086        Permission::InsertColumns { table, columns } => (1, table, columns),
16087        Permission::UpdateColumns { table, columns } => (2, table, columns),
16088        permission if !permissions.contains(&permission) => {
16089            permissions.push(permission);
16090            return;
16091        }
16092        _ => return,
16093    };
16094    for permission in permissions.iter_mut() {
16095        let existing = match permission {
16096            Permission::SelectColumns {
16097                table: existing_table,
16098                columns,
16099            } if kind == 0 && existing_table == &table => Some(columns),
16100            Permission::InsertColumns {
16101                table: existing_table,
16102                columns,
16103            } if kind == 1 && existing_table == &table => Some(columns),
16104            Permission::UpdateColumns {
16105                table: existing_table,
16106                columns,
16107            } if kind == 2 && existing_table == &table => Some(columns),
16108            _ => None,
16109        };
16110        if let Some(existing) = existing {
16111            existing.append(&mut columns);
16112            existing.sort();
16113            existing.dedup();
16114            return;
16115        }
16116    }
16117    columns.sort();
16118    columns.dedup();
16119    let permission = if kind == 0 {
16120        Permission::SelectColumns { table, columns }
16121    } else if kind == 1 {
16122        Permission::InsertColumns { table, columns }
16123    } else {
16124        Permission::UpdateColumns { table, columns }
16125    };
16126    permissions.push(permission);
16127}
16128
16129fn revoke_permission_from(
16130    permissions: &mut Vec<crate::auth::Permission>,
16131    revoked: &crate::auth::Permission,
16132) {
16133    use crate::auth::Permission;
16134    let revoked_columns = match revoked {
16135        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
16136        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
16137        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
16138        _ => None,
16139    };
16140    let Some((kind, table, columns)) = revoked_columns else {
16141        permissions.retain(|permission| permission != revoked);
16142        return;
16143    };
16144    for permission in permissions.iter_mut() {
16145        let current = match permission {
16146            Permission::SelectColumns {
16147                table: current_table,
16148                columns,
16149            } if kind == 0 && current_table == table => Some(columns),
16150            Permission::InsertColumns {
16151                table: current_table,
16152                columns,
16153            } if kind == 1 && current_table == table => Some(columns),
16154            Permission::UpdateColumns {
16155                table: current_table,
16156                columns,
16157            } if kind == 2 && current_table == table => Some(columns),
16158            _ => None,
16159        };
16160        if let Some(current) = current {
16161            current.retain(|column| !columns.contains(column));
16162        }
16163    }
16164    permissions.retain(|permission| match permission {
16165        Permission::SelectColumns { columns, .. }
16166        | Permission::InsertColumns { columns, .. }
16167        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
16168        _ => true,
16169    });
16170}
16171
16172fn validate_security_catalog(
16173    catalog: &Catalog,
16174    security: &crate::security::SecurityCatalog,
16175) -> Result<()> {
16176    let mut policy_names = HashSet::new();
16177    for table in &security.rls_tables {
16178        if catalog.live(table).is_none() {
16179            return Err(MongrelError::NotFound(format!(
16180                "RLS table {table:?} not found"
16181            )));
16182        }
16183    }
16184    for policy in &security.policies {
16185        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
16186            return Err(MongrelError::InvalidArgument(format!(
16187                "duplicate policy {:?} on {:?}",
16188                policy.name, policy.table
16189            )));
16190        }
16191        let schema = &catalog
16192            .live(&policy.table)
16193            .ok_or_else(|| {
16194                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
16195            })?
16196            .schema;
16197        if let Some(expression) = &policy.using {
16198            validate_security_expression(expression, schema)?;
16199        }
16200        if let Some(expression) = &policy.with_check {
16201            validate_security_expression(expression, schema)?;
16202        }
16203    }
16204    let mut mask_names = HashSet::new();
16205    for mask in &security.masks {
16206        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
16207            return Err(MongrelError::InvalidArgument(format!(
16208                "duplicate mask {:?} on {:?}",
16209                mask.name, mask.table
16210            )));
16211        }
16212        let column = catalog
16213            .live(&mask.table)
16214            .and_then(|entry| {
16215                entry
16216                    .schema
16217                    .columns
16218                    .iter()
16219                    .find(|column| column.id == mask.column)
16220            })
16221            .ok_or_else(|| {
16222                MongrelError::NotFound(format!(
16223                    "mask column {} on {:?} not found",
16224                    mask.column, mask.table
16225                ))
16226            })?;
16227        if matches!(
16228            mask.strategy,
16229            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
16230        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
16231        {
16232            return Err(MongrelError::InvalidArgument(format!(
16233                "mask {:?} requires a string/bytes column",
16234                mask.name
16235            )));
16236        }
16237    }
16238    Ok(())
16239}
16240
16241fn validate_security_expression(
16242    expression: &crate::security::SecurityExpr,
16243    schema: &Schema,
16244) -> Result<()> {
16245    use crate::security::SecurityExpr;
16246    match expression {
16247        SecurityExpr::True => Ok(()),
16248        SecurityExpr::ColumnEqCurrentUser { column }
16249        | SecurityExpr::ColumnEqValue { column, .. } => {
16250            if schema
16251                .columns
16252                .iter()
16253                .any(|candidate| candidate.id == *column)
16254            {
16255                Ok(())
16256            } else {
16257                Err(MongrelError::InvalidArgument(format!(
16258                    "security expression references unknown column id {column}"
16259                )))
16260            }
16261        }
16262        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
16263            validate_security_expression(left, schema)?;
16264            validate_security_expression(right, schema)
16265        }
16266        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
16267    }
16268}
16269
16270/// Remove canonical numeric table directories that no catalog generation owns.
16271fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
16272    let referenced = cat
16273        .tables
16274        .iter()
16275        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
16276        .map(|entry| entry.table_id)
16277        .collect::<HashSet<_>>();
16278    let tables_dir = root.join(TABLES_DIR);
16279    let entries = match std::fs::read_dir(&tables_dir) {
16280        Ok(entries) => entries,
16281        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
16282        Err(error) => return Err(error.into()),
16283    };
16284    for entry in entries {
16285        let entry = entry?;
16286        if !entry.file_type()?.is_dir() {
16287            continue;
16288        }
16289        let file_name = entry.file_name();
16290        let Some(name) = file_name.to_str() else {
16291            continue;
16292        };
16293        let Ok(table_id) = name.parse::<u64>() else {
16294            continue;
16295        };
16296        if name != table_id.to_string() {
16297            continue;
16298        }
16299        if !referenced.contains(&table_id) {
16300            crate::durable_file::remove_directory_all(&entry.path())?;
16301        }
16302    }
16303    Ok(())
16304}
16305
16306/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
16307/// #14). These dirs hold pending uniform-epoch runs from large transactions
16308/// that were aborted or crashed before commit. On open, all such dirs are safe
16309/// to remove because committed txns moved their runs to `_runs/` at publish.
16310fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
16311    for entry in &cat.tables {
16312        let txn_dir = root
16313            .join(TABLES_DIR)
16314            .join(entry.table_id.to_string())
16315            .join("_txn");
16316        if txn_dir.exists() {
16317            let _ = std::fs::remove_dir_all(&txn_dir);
16318        }
16319    }
16320}
16321
16322#[cfg(test)]
16323mod write_permission_tests {
16324    use super::*;
16325    use crate::txn::Staged;
16326
16327    struct NoopExternalBridge;
16328
16329    impl ExternalTriggerBridge for NoopExternalBridge {
16330        fn apply_trigger_external_write(
16331            &self,
16332            _entry: &ExternalTableEntry,
16333            base_state: Vec<u8>,
16334            _op: ExternalTriggerWrite,
16335        ) -> Result<ExternalTriggerWriteResult> {
16336            Ok(ExternalTriggerWriteResult::new(base_state))
16337        }
16338    }
16339
16340    fn assert_txn_namespace_full<T>(result: Result<T>) {
16341        assert!(matches!(result, Err(MongrelError::Full(_))));
16342    }
16343
16344    #[test]
16345    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
16346        let directory = tempfile::tempdir().unwrap();
16347        let database = Database::create(directory.path()).unwrap();
16348        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
16349        *database.next_txn_id.lock() = generation << 32;
16350        let before = crate::wal::SharedWal::replay(directory.path())
16351            .unwrap()
16352            .len();
16353        let bridge = NoopExternalBridge;
16354
16355        assert_txn_namespace_full(database.begin().commit());
16356        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
16357        assert_txn_namespace_full(
16358            database
16359                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
16360                .commit(),
16361        );
16362        assert_txn_namespace_full(
16363            database
16364                .begin_with_external_trigger_bridge(&bridge)
16365                .commit(),
16366        );
16367        assert_txn_namespace_full(
16368            database
16369                .begin_with_external_trigger_bridge_as(&bridge, None)
16370                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
16371        );
16372
16373        assert_eq!(
16374            crate::wal::SharedWal::replay(directory.path())
16375                .unwrap()
16376                .len(),
16377            before
16378        );
16379        drop(database);
16380        Database::open(directory.path()).unwrap();
16381    }
16382
16383    #[test]
16384    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
16385        let directory = tempfile::tempdir().unwrap();
16386        let table_dir = directory.path().join("7");
16387        crate::durable_file::create_directory_all(&table_dir).unwrap();
16388        let original_schema = test_schema();
16389        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
16390        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
16391        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
16392        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
16393        let original_bytes = std::fs::read(&schema_path).unwrap();
16394
16395        let mut replacement_schema = original_schema;
16396        replacement_schema.schema_id += 1;
16397        assert!(matches!(
16398            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
16399            Err(MongrelError::Conflict(_))
16400        ));
16401
16402        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
16403        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
16404        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
16405        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
16406    }
16407
16408    #[test]
16409    fn catalog_table_missing_storage_fails_without_recreating_it() {
16410        let directory = tempfile::tempdir().unwrap();
16411        let table_dir = {
16412            let database = Database::create(directory.path()).unwrap();
16413            database.create_table("docs", test_schema()).unwrap();
16414            directory
16415                .path()
16416                .join(TABLES_DIR)
16417                .join(database.table_id("docs").unwrap().to_string())
16418        };
16419        std::fs::remove_dir_all(&table_dir).unwrap();
16420
16421        assert!(matches!(
16422            Database::open(directory.path()),
16423            Err(MongrelError::NotFound(_))
16424        ));
16425        assert!(!table_dir.exists());
16426    }
16427
16428    #[test]
16429    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
16430        let directory = tempfile::tempdir().unwrap();
16431        let database = std::sync::Arc::new(
16432            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
16433        );
16434        database.create_user("alice", "old-password").unwrap();
16435        let old_identity = database.user_identity("alice").unwrap();
16436        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
16437        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
16438        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
16439        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
16440
16441        std::thread::scope(|scope| {
16442            let authenticate = {
16443                let database = std::sync::Arc::clone(&database);
16444                scope.spawn(move || {
16445                    database.authenticate_principal_inner("alice", "old-password", || {
16446                        verified_tx.send(()).unwrap();
16447                        resume_rx.recv().unwrap();
16448                    })
16449                })
16450            };
16451            verified_rx.recv().unwrap();
16452            let mutate = {
16453                let database = std::sync::Arc::clone(&database);
16454                scope.spawn(move || {
16455                    mutation_started_tx.send(()).unwrap();
16456                    database.drop_user("alice").unwrap();
16457                    database.create_user("alice", "new-password").unwrap();
16458                    mutation_done_tx.send(()).unwrap();
16459                })
16460            };
16461            mutation_started_rx.recv().unwrap();
16462            assert!(mutation_done_rx
16463                .recv_timeout(std::time::Duration::from_millis(50))
16464                .is_err());
16465            resume_tx.send(()).unwrap();
16466            let principal = authenticate.join().unwrap().unwrap().unwrap();
16467            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
16468            mutate.join().unwrap();
16469        });
16470
16471        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
16472        assert!(database
16473            .authenticate_principal("alice", "old-password")
16474            .unwrap()
16475            .is_none());
16476        assert!(database
16477            .authenticate_principal("alice", "new-password")
16478            .unwrap()
16479            .is_some());
16480    }
16481
16482    #[test]
16483    fn homogeneous_batch_summarizes_to_one_permission_decision() {
16484        let staging = (0..10_050)
16485            .map(|_| {
16486                (
16487                    7,
16488                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
16489                )
16490            })
16491            .collect::<Vec<_>>();
16492
16493        let needs = summarize_write_permissions(&staging);
16494        let table = needs.get(&7).unwrap();
16495        assert_eq!(needs.len(), 1);
16496        assert!(table.insert);
16497        assert_eq!(table.insert_columns, [1, 2]);
16498        assert!(!table.update);
16499        assert!(!table.delete);
16500        assert!(!table.truncate);
16501    }
16502
16503    #[test]
16504    fn mixed_writes_union_columns_and_preserve_empty_operations() {
16505        let staging = vec![
16506            (7, Staged::Put(vec![(2, Value::Int64(2))])),
16507            (7, Staged::Put(vec![(1, Value::Int64(1))])),
16508            (
16509                7,
16510                Staged::Update {
16511                    row_id: RowId(1),
16512                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
16513                    changed_columns: vec![2],
16514                },
16515            ),
16516            (7, Staged::Delete(RowId(2))),
16517            (8, Staged::Truncate),
16518        ];
16519
16520        let needs = summarize_write_permissions(&staging);
16521        let table = needs.get(&7).unwrap();
16522        assert_eq!(table.insert_columns, [1, 2]);
16523        assert!(table.update);
16524        assert_eq!(table.update_columns, [2]);
16525        assert!(table.delete);
16526        assert!(needs.get(&8).unwrap().truncate);
16527    }
16528
16529    #[test]
16530    fn final_permission_decisions_do_not_scale_with_rows() {
16531        let credentialless_dir = tempfile::tempdir().unwrap();
16532        let credentialless = Database::create(credentialless_dir.path()).unwrap();
16533        credentialless.create_table("docs", test_schema()).unwrap();
16534        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16535        credentialless
16536            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
16537            .unwrap();
16538        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
16539
16540        let authenticated_dir = tempfile::tempdir().unwrap();
16541        let authenticated =
16542            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
16543                .unwrap();
16544        authenticated.create_table("docs", test_schema()).unwrap();
16545        let admin = authenticated.resolve_principal("admin").unwrap();
16546        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16547        authenticated
16548            .validate_write_permissions(
16549                &puts(authenticated.table_id("docs").unwrap()),
16550                Some(&admin),
16551                None,
16552            )
16553            .unwrap();
16554        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16555    }
16556
16557    #[test]
16558    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
16559        let dir = tempfile::tempdir().unwrap();
16560        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16561        db.create_table("docs", test_schema()).unwrap();
16562        let admin = db.resolve_principal("admin").unwrap();
16563        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16564
16565        let mut transaction = db.begin_as(Some(admin));
16566        transaction
16567            .delete_batch("docs", (0..100).map(RowId).collect())
16568            .unwrap();
16569        transaction.commit().unwrap();
16570
16571        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
16572    }
16573
16574    #[test]
16575    fn truncate_validation_checks_admin_once_for_all_tables() {
16576        let dir = tempfile::tempdir().unwrap();
16577        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16578        db.create_table("first", test_schema()).unwrap();
16579        db.create_table("second", test_schema()).unwrap();
16580        let admin = db.resolve_principal("admin").unwrap();
16581        let staging = vec![
16582            (db.table_id("first").unwrap(), Staged::Truncate),
16583            (db.table_id("second").unwrap(), Staged::Truncate),
16584        ];
16585
16586        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16587        db.validate_write_permissions(&staging, Some(&admin), None)
16588            .unwrap();
16589        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16590    }
16591
16592    #[test]
16593    fn one_table_commit_batches_structural_work() {
16594        let dir = tempfile::tempdir().unwrap();
16595        let db = Database::create(dir.path()).unwrap();
16596        db.create_table("docs", test_schema()).unwrap();
16597        let table_id = db.table_id("docs").unwrap();
16598
16599        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
16600        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16601        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16602        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16603        db.transaction(|transaction| {
16604            for id in 0..100 {
16605                transaction.put("docs", vec![(1, Value::Int64(id))])?;
16606            }
16607            Ok(())
16608        })
16609        .unwrap();
16610
16611        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
16612        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16613        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16614        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16615
16616        let puts = crate::wal::SharedWal::replay(dir.path())
16617            .unwrap()
16618            .into_iter()
16619            .filter_map(|record| match record.op {
16620                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
16621                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
16622                        .unwrap()
16623                        .len(),
16624                ),
16625                _ => None,
16626            })
16627            .collect::<Vec<_>>();
16628        assert_eq!(puts, [100]);
16629
16630        let row_ids = db
16631            .table("docs")
16632            .unwrap()
16633            .lock()
16634            .visible_rows(db.snapshot().0)
16635            .unwrap()
16636            .into_iter()
16637            .take(2)
16638            .map(|row| row.row_id)
16639            .collect::<Vec<_>>();
16640        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16641        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16642        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16643        db.transaction(|transaction| {
16644            for row_id in row_ids {
16645                transaction.delete("docs", row_id)?;
16646            }
16647            Ok(())
16648        })
16649        .unwrap();
16650        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16651        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16652        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16653
16654        let deletes = crate::wal::SharedWal::replay(dir.path())
16655            .unwrap()
16656            .into_iter()
16657            .filter_map(|record| match record.op {
16658                crate::wal::Op::Delete {
16659                    table_id: id,
16660                    row_ids,
16661                } if id == table_id => Some(row_ids.len()),
16662                _ => None,
16663            })
16664            .collect::<Vec<_>>();
16665        assert_eq!(deletes, [2]);
16666    }
16667
16668    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
16669        (0..10_050)
16670            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
16671            .collect()
16672    }
16673
16674    fn test_schema() -> Schema {
16675        Schema {
16676            columns: vec![ColumnDef {
16677                id: 1,
16678                name: "id".into(),
16679                ty: TypeId::Int64,
16680                flags: crate::schema::ColumnFlags::empty()
16681                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
16682                default_value: None,
16683            }],
16684            ..Schema::default()
16685        }
16686    }
16687}
16688
16689#[cfg(test)]
16690mod cdc_bounds_tests {
16691    use super::*;
16692
16693    #[test]
16694    fn retained_byte_limit_rejects_without_allocating_payload() {
16695        let mut retained = 0;
16696        let error = charge_cdc_bytes(
16697            &mut retained,
16698            CDC_MAX_RETAINED_BYTES.saturating_add(1),
16699            "CDC retained bytes",
16700        )
16701        .unwrap_err();
16702        assert!(matches!(
16703            error,
16704            MongrelError::ResourceLimitExceeded {
16705                resource: "CDC retained bytes",
16706                ..
16707            }
16708        ));
16709    }
16710
16711    #[test]
16712    fn row_json_estimate_accounts_for_byte_array_expansion() {
16713        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
16714            .with_column(1, Value::Bytes(vec![0; 1024]));
16715        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
16716    }
16717}
16718
16719#[cfg(test)]
16720mod generation_metrics_tests {
16721    use super::*;
16722    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
16723
16724    #[test]
16725    fn legacy_cow_fallback_is_measured() {
16726        let dir = tempfile::tempdir().unwrap();
16727        let table = Table::create(
16728            dir.path(),
16729            Schema {
16730                columns: vec![ColumnDef {
16731                    id: 1,
16732                    name: "id".into(),
16733                    ty: TypeId::Int64,
16734                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
16735                    default_value: None,
16736                }],
16737                ..Schema::default()
16738            },
16739            1,
16740        )
16741        .unwrap();
16742        let handle = TableHandle::from_table(table);
16743        let held = match &handle.inner {
16744            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
16745            TableHandleInner::Direct(_) => unreachable!(),
16746        };
16747
16748        handle.lock().set_sync_byte_threshold(1);
16749
16750        let stats = handle.generation_stats();
16751        assert_eq!(stats.cow_clone_count, 1);
16752        assert!(stats.estimated_cow_clone_bytes > 0);
16753        drop(held);
16754    }
16755}
16756
16757#[cfg(test)]
16758mod trigger_engine_tests {
16759    use super::*;
16760
16761    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
16762        WriteEvent {
16763            table: "test".into(),
16764            kind: TriggerEvent::Insert,
16765            new: Some(TriggerRowImage {
16766                columns: new_cells.iter().cloned().collect(),
16767            }),
16768            old: Some(TriggerRowImage {
16769                columns: old_cells.iter().cloned().collect(),
16770            }),
16771            changed_columns: Vec::new(),
16772            op_indices: Vec::new(),
16773            put_idx: None,
16774            trigger_stack: Vec::new(),
16775        }
16776    }
16777
16778    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
16779        WriteEvent {
16780            table: "test".into(),
16781            kind: TriggerEvent::Insert,
16782            new: Some(TriggerRowImage {
16783                columns: new_cells.iter().cloned().collect(),
16784            }),
16785            old: None,
16786            changed_columns: Vec::new(),
16787            op_indices: Vec::new(),
16788            put_idx: None,
16789            trigger_stack: Vec::new(),
16790        }
16791    }
16792
16793    #[test]
16794    fn value_order_int64_vs_float64() {
16795        assert_eq!(
16796            value_order(&Value::Int64(5), &Value::Float64(5.0)),
16797            Some(std::cmp::Ordering::Equal)
16798        );
16799        assert_eq!(
16800            value_order(&Value::Int64(5), &Value::Float64(3.0)),
16801            Some(std::cmp::Ordering::Greater)
16802        );
16803        assert_eq!(
16804            value_order(&Value::Int64(2), &Value::Float64(3.0)),
16805            Some(std::cmp::Ordering::Less)
16806        );
16807    }
16808
16809    #[test]
16810    fn value_order_null_returns_none() {
16811        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
16812        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
16813        assert_eq!(value_order(&Value::Null, &Value::Null), None);
16814    }
16815
16816    #[test]
16817    fn value_order_cross_group_returns_none() {
16818        assert_eq!(
16819            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
16820            None
16821        );
16822        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
16823        assert_eq!(
16824            value_order(
16825                &Value::Embedding(vec![1.0, 2.0]),
16826                &Value::Embedding(vec![1.0, 2.0])
16827            ),
16828            None
16829        );
16830    }
16831
16832    #[test]
16833    fn eval_trigger_expr_ranges_and_booleans() {
16834        let expr = TriggerExpr::And {
16835            left: Box::new(TriggerExpr::Gt {
16836                left: TriggerValue::NewColumn(1),
16837                right: TriggerValue::Literal(Value::Int64(0)),
16838            }),
16839            right: Box::new(TriggerExpr::Lte {
16840                left: TriggerValue::NewColumn(1),
16841                right: TriggerValue::Literal(Value::Int64(100)),
16842            }),
16843        };
16844        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
16845        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
16846        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
16847
16848        let or_expr = TriggerExpr::Or {
16849            left: Box::new(TriggerExpr::Lt {
16850                left: TriggerValue::NewColumn(1),
16851                right: TriggerValue::Literal(Value::Int64(0)),
16852            }),
16853            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
16854                TriggerValue::OldColumn(2),
16855            )))),
16856        };
16857        assert!(eval_trigger_expr(
16858            &or_expr,
16859            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
16860        )
16861        .unwrap());
16862        assert!(!eval_trigger_expr(
16863            &or_expr,
16864            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
16865        )
16866        .unwrap());
16867
16868        assert!(eval_trigger_expr(
16869            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
16870            &event_insert(&[])
16871        )
16872        .unwrap());
16873        assert!(!eval_trigger_expr(
16874            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
16875            &event_insert(&[])
16876        )
16877        .unwrap());
16878        assert!(!eval_trigger_expr(
16879            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
16880            &event_insert(&[])
16881        )
16882        .unwrap());
16883    }
16884}