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    /// Run a hybrid scored search over only rows authorized for this database
4349    /// handle. Applies retriever column grants, RLS, and masks to the returned
4350    /// hits.
4351    pub fn search_for_current_principal(
4352        &self,
4353        table_name: &str,
4354        request: &crate::query::SearchRequest,
4355    ) -> Result<Vec<crate::query::SearchHit>> {
4356        let mut columns = crate::query::condition_columns(&request.must);
4357        for named in &request.retrievers {
4358            columns.push(named.retriever.column_id());
4359        }
4360        if let Some(crate::query::Rerank::ExactVector { embedding_column, .. }) = &request.rerank {
4361            columns.push(*embedding_column);
4362        }
4363        if let Some(proj) = &request.projection {
4364            columns.extend(proj);
4365        }
4366        columns.sort_unstable();
4367        columns.dedup();
4368        self.with_authorized_scored_read_context_at(
4369            table_name,
4370            None,
4371            true,
4372            Some(&ReadAuthorization {
4373                operation: crate::auth::ColumnOperation::Select,
4374                columns: columns.clone(),
4375                permissions: Vec::new(),
4376            }),
4377            None,
4378            None,
4379            |table, snapshot, authorization, principal| {
4380                self.require_columns_for(
4381                    table_name,
4382                    crate::auth::ColumnOperation::Select,
4383                    &columns,
4384                    principal,
4385                )?;
4386                let hits = table.search_at_with_candidate_authorization_on_generation(
4387                    request,
4388                    snapshot,
4389                    authorization,
4390                    None,
4391                )?;
4392                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4393                let mut secured: Vec<crate::query::SearchHit> = Vec::with_capacity(hits.len());
4394                for mut hit in hits {
4395                    let row = crate::memtable::Row {
4396                        row_id: hit.row_id,
4397                        committed_epoch: crate::Epoch(0),
4398                        columns: hit.cells.iter().cloned().collect::<std::collections::HashMap<u16, crate::Value>>(),
4399                        deleted: false,
4400                    };
4401                    let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
4402                    if let Some(mut row) = rows.pop() {
4403                        row.columns.retain(|column, _| allowed_columns.contains(column));
4404                        hit.cells = row.columns.into_iter().collect::<Vec<_>>();
4405                        hit.cells.sort_by_key(|(column, _)| *column);
4406                        secured.push(hit);
4407                    }
4408                }
4409                Ok(secured)
4410            },
4411        )
4412    }
4413
4414    /// Capture one table snapshot and the security version used to authorize it.
4415    /// The caller must validate the returned version before publishing results.
4416    pub fn authorized_read_snapshot(
4417        &self,
4418        table: &str,
4419        principal: Option<&crate::auth::Principal>,
4420    ) -> Result<AuthorizedReadSnapshot> {
4421        let (security, security_version, effective_principal) = {
4422            let catalog = self.catalog.read();
4423            (
4424                catalog.security.clone(),
4425                catalog.security_version,
4426                self.principal_for_authorized_read(&catalog, principal, false)?,
4427            )
4428        };
4429        let handle = self.table(table)?;
4430        let (table_snapshot, data_generation, allowed_row_ids) = {
4431            let table_handle = handle.lock();
4432            let table_snapshot = table_handle.snapshot();
4433            let data_generation = table_handle.data_generation();
4434            let allowed = self.allowed_row_ids_locked(
4435                table,
4436                &table_handle,
4437                table_snapshot,
4438                (&security, security_version),
4439                effective_principal.as_ref(),
4440                None,
4441            )?;
4442            (
4443                table_snapshot,
4444                data_generation,
4445                allowed.map(|allowed| (*allowed).clone()),
4446            )
4447        };
4448        Ok(AuthorizedReadSnapshot {
4449            table: table.to_string(),
4450            table_snapshot,
4451            data_generation,
4452            security_version,
4453            allowed_row_ids,
4454        })
4455    }
4456
4457    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
4458        if self.catalog.read().security_version != snapshot.security_version {
4459            return false;
4460        }
4461        self.table(&snapshot.table)
4462            .ok()
4463            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
4464    }
4465
4466    pub fn rls_cache_stats(&self) -> RlsCacheStats {
4467        self.rls_cache.lock().stats()
4468    }
4469
4470    /// Read visible rows with column authorization, RLS, and masks applied.
4471    pub fn rows_for(
4472        &self,
4473        table: &str,
4474        principal: Option<&crate::auth::Principal>,
4475    ) -> Result<Vec<crate::memtable::Row>> {
4476        if principal.is_none() && self.principal.read().is_some() {
4477            self.refresh_principal()?;
4478        }
4479        let allowed = self.select_column_ids_for(table, principal)?;
4480        let handle = self.table(table)?;
4481        let rows = {
4482            let table = handle.lock();
4483            table.visible_rows(table.snapshot())?
4484        };
4485        let mut rows = self.secure_rows_for(table, rows, principal)?;
4486        for row in &mut rows {
4487            row.columns.retain(|column, _| allowed.contains(column));
4488        }
4489        Ok(rows)
4490    }
4491
4492    /// Historical rows use the current principal and security catalog against
4493    /// the row values visible at the requested snapshot.
4494    pub fn rows_at_epoch_for_current_principal(
4495        &self,
4496        table_name: &str,
4497        snapshot: Snapshot,
4498    ) -> Result<Vec<crate::memtable::Row>> {
4499        self.with_authorized_read_context(
4500            table_name,
4501            None,
4502            true,
4503            Some(&ReadAuthorization {
4504                operation: crate::auth::ColumnOperation::Select,
4505                columns: Vec::new(),
4506                permissions: Vec::new(),
4507            }),
4508            None,
4509            Some(snapshot),
4510            |table, snapshot, allowed, principal| {
4511                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4512                let mut rows = table.visible_rows(snapshot)?;
4513                if let Some(allowed) = allowed {
4514                    rows.retain(|row| allowed.contains(&row.row_id));
4515                }
4516                rows = self.secure_rows_for(table_name, rows, principal)?;
4517                for row in &mut rows {
4518                    row.columns
4519                        .retain(|column, _| allowed_columns.contains(column));
4520                }
4521                Ok(rows)
4522            },
4523        )
4524    }
4525
4526    /// Count rows visible to a principal without bypassing RLS.
4527    pub fn count_for(
4528        &self,
4529        table: &str,
4530        principal: Option<&crate::auth::Principal>,
4531    ) -> Result<u64> {
4532        if principal.is_none() && self.principal.read().is_some() {
4533            self.refresh_principal()?;
4534        }
4535        self.select_column_ids_for(table, principal)?;
4536        if self.security_active_for(table) {
4537            Ok(self.rows_for(table, principal)?.len() as u64)
4538        } else {
4539            Ok(self.table(table)?.lock().count())
4540        }
4541    }
4542
4543    /// Authorize and write one native-API row for an explicit principal.
4544    pub fn put_for(
4545        &self,
4546        table: &str,
4547        mut cells: Vec<(u16, crate::memtable::Value)>,
4548        principal: Option<&crate::auth::Principal>,
4549    ) -> Result<RowId> {
4550        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
4551        self.require_columns_for(
4552            table,
4553            crate::auth::ColumnOperation::Insert,
4554            &columns,
4555            principal,
4556        )?;
4557        let handle = self.table(table)?;
4558        let mut table_handle = handle.lock();
4559        table_handle.fill_auto_inc(&mut cells)?;
4560        table_handle.apply_defaults(&mut cells)?;
4561        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
4562        row.columns.extend(cells.iter().cloned());
4563        self.check_row_policy_for(
4564            table,
4565            crate::security::PolicyCommand::Insert,
4566            &row,
4567            true,
4568            principal,
4569        )?;
4570        table_handle.put(cells)
4571    }
4572
4573    pub fn check_row_policy_for(
4574        &self,
4575        table: &str,
4576        command: crate::security::PolicyCommand,
4577        row: &crate::memtable::Row,
4578        check_new: bool,
4579        principal: Option<&crate::auth::Principal>,
4580    ) -> Result<()> {
4581        let security = self.catalog.read().security.clone();
4582        if !security.rls_enabled(table) {
4583            return Ok(());
4584        }
4585        let cached = self.principal.read().clone();
4586        let principal = principal
4587            .or(cached.as_ref())
4588            .ok_or(MongrelError::AuthRequired)?;
4589        if security.row_allowed(table, command, row, principal, check_new) {
4590            return Ok(());
4591        }
4592        let required = match command {
4593            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
4594                table: table.to_string(),
4595            },
4596            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
4597                table: table.to_string(),
4598            },
4599            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
4600                table: table.to_string(),
4601            },
4602            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
4603                crate::auth::Permission::Delete {
4604                    table: table.to_string(),
4605                }
4606            }
4607        };
4608        Err(MongrelError::PermissionDenied {
4609            required,
4610            principal: principal.username.clone(),
4611        })
4612    }
4613
4614    /// Durably create or replace a materialized-view definition after its
4615    /// physical table has been populated.
4616    pub fn set_materialized_view(
4617        &self,
4618        definition: crate::catalog::MaterializedViewEntry,
4619    ) -> Result<()> {
4620        self.set_materialized_view_with_epoch(definition)
4621            .map(|_| ())
4622    }
4623
4624    /// Durably create or replace a materialized-view definition and return its epoch.
4625    pub fn set_materialized_view_with_epoch(
4626        &self,
4627        definition: crate::catalog::MaterializedViewEntry,
4628    ) -> Result<Epoch> {
4629        use crate::wal::DdlOp;
4630        use std::sync::atomic::Ordering;
4631
4632        self.require(&crate::auth::Permission::Ddl)?;
4633        if self.poisoned.load(Ordering::Relaxed) {
4634            return Err(MongrelError::Other(
4635                "database poisoned by fsync error".into(),
4636            ));
4637        }
4638        if definition.name.is_empty() || definition.query.trim().is_empty() {
4639            return Err(MongrelError::InvalidArgument(
4640                "materialized view name and query must not be empty".into(),
4641            ));
4642        }
4643
4644        let _ddl = self.ddl_lock.lock();
4645        let _security_write = self.security_write()?;
4646        self.require(&crate::auth::Permission::Ddl)?;
4647        let table_id = self
4648            .catalog
4649            .read()
4650            .live(&definition.name)
4651            .ok_or_else(|| {
4652                MongrelError::NotFound(format!(
4653                    "materialized view table {:?} not found",
4654                    definition.name
4655                ))
4656            })?
4657            .table_id;
4658        let definition_json = DdlOp::encode_materialized_view(&definition)?;
4659        let _commit = self.commit_lock.lock();
4660        let epoch = self.epoch.bump_assigned();
4661        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4662        let txn_id = self.alloc_txn_id()?;
4663        let mut next_catalog = self.catalog.read().clone();
4664        if let Some(existing) = next_catalog
4665            .materialized_views
4666            .iter_mut()
4667            .find(|existing| existing.name == definition.name)
4668        {
4669            *existing = definition.clone();
4670        } else {
4671            next_catalog.materialized_views.push(definition.clone());
4672        }
4673        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4674        let commit_seq = {
4675            let mut wal = self.shared_wal.lock();
4676            let append: Result<u64> = (|| {
4677                wal.append(
4678                    txn_id,
4679                    table_id,
4680                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
4681                        name: definition.name.clone(),
4682                        definition_json,
4683                    }),
4684                )?;
4685                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4686                wal.append_commit(txn_id, epoch, &[])
4687            })();
4688            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4689        };
4690        self.await_durable_commit(commit_seq, epoch)?;
4691
4692        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4693        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
4694        Ok(epoch)
4695    }
4696
4697    /// The filesystem root this database was opened/created at.
4698    pub fn root(&self) -> &Path {
4699        self.durable_root.canonical_path()
4700    }
4701
4702    /// Open a descriptor-pinned view of this database root for durable
4703    /// extension state such as server idempotency receipts.
4704    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
4705        Arc::clone(&self.durable_root)
4706    }
4707
4708    /// Domain-separated authentication key for server idempotency state.
4709    /// Encrypted databases derive it from the in-memory KEK. Plain databases
4710    /// return `None`; their server persists a random key under the pinned root.
4711    #[cfg(feature = "encryption")]
4712    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4713        self.kek
4714            .as_deref()
4715            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
4716    }
4717
4718    #[cfg(not(feature = "encryption"))]
4719    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4720        None
4721    }
4722
4723    pub fn is_read_only_replica(&self) -> bool {
4724        self.read_only
4725    }
4726
4727    /// Reject reads whose backing state may require WAL recovery after a
4728    /// post-commit publication failure. Ordinary table/catalog state is made
4729    /// coherent before poison; file-backed external modules use this gate.
4730    pub fn ensure_consistent_read(&self) -> Result<()> {
4731        self.ensure_owner_process()?;
4732        if self.poisoned.load(Ordering::Relaxed) {
4733            return Err(MongrelError::Other(
4734                "database poisoned by post-commit failure; reopen required".into(),
4735            ));
4736        }
4737        Ok(())
4738    }
4739
4740    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
4741        self.replication_wal_retention_segments
4742            .store(segments, std::sync::atomic::Ordering::Relaxed);
4743    }
4744
4745    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
4746    /// direct table commits, compaction, and WAL append are quiesced while the
4747    /// file image is read. WAL records newer than manifests remain sufficient
4748    /// for recovery, so no flush or compaction is required.
4749    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
4750        let admin = crate::auth::Permission::Admin;
4751        self.require(&admin)?;
4752        let operation_principal = self.principal_snapshot();
4753        let _barrier = self.replication_barrier.write();
4754        let _ddl = self.ddl_lock.lock();
4755        let _security = self.security_coordinator.gate.read();
4756        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4757        let mut handles: Vec<_> = self
4758            .tables
4759            .read()
4760            .iter()
4761            .map(|(id, handle)| (*id, handle.clone()))
4762            .collect();
4763        handles.sort_by_key(|(id, _)| *id);
4764        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4765        let _commit = self.commit_lock.lock();
4766        let mut wal = self.shared_wal.lock();
4767        wal.group_sync()?;
4768        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4769        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4770        let epoch = records
4771            .iter()
4772            .filter_map(|record| match &record.op {
4773                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
4774                _ => None,
4775            })
4776            .max()
4777            .unwrap_or(0)
4778            .max(self.epoch.committed().0);
4779        let files = crate::replication::capture_files(&self.root)?;
4780        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4781        drop(wal);
4782        Ok(crate::replication::ReplicationSnapshot::new(
4783            source_id, epoch, files,
4784        ))
4785    }
4786
4787    /// Create an online, directly-openable backup at `destination`.
4788    ///
4789    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
4790    /// mutable metadata, and pins the exact immutable runs named by the copied
4791    /// manifests. Writers resume while those runs stream into a sibling staging
4792    /// directory. A checksummed backup manifest is written last, then the stage
4793    /// is atomically renamed into place.
4794    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
4795        let control = crate::ExecutionControl::new(None);
4796        self.hot_backup_controlled(destination, &control, || true)
4797    }
4798
4799    pub(crate) fn hot_backup_to_durable_child(
4800        &self,
4801        parent: &crate::durable_file::DurableRoot,
4802        child: &Path,
4803        control: &crate::ExecutionControl,
4804    ) -> Result<crate::backup::BackupReport> {
4805        let mut components = child.components();
4806        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
4807            || components.next().is_some()
4808        {
4809            return Err(MongrelError::InvalidArgument(
4810                "durable backup child must be one relative path component".into(),
4811            ));
4812        }
4813        let destination_name = child.file_name().ok_or_else(|| {
4814            MongrelError::InvalidArgument("durable backup child has no filename".into())
4815        })?;
4816        let prepared = prepare_backup_destination_in(&self.root, parent, destination_name)?;
4817        self.hot_backup_prepared(prepared, control, || true)
4818    }
4819
4820    /// Build a backup cooperatively, then invoke `before_publish` immediately
4821    /// before the staging directory is atomically renamed into place.
4822    #[doc(hidden)]
4823    pub fn hot_backup_controlled<F>(
4824        &self,
4825        destination: impl AsRef<Path>,
4826        control: &crate::ExecutionControl,
4827        before_publish: F,
4828    ) -> Result<crate::backup::BackupReport>
4829    where
4830        F: FnOnce() -> bool,
4831    {
4832        let prepared = prepare_backup_destination(&self.root, destination.as_ref())?;
4833        self.hot_backup_prepared(prepared, control, before_publish)
4834    }
4835
4836    fn hot_backup_prepared<F>(
4837        &self,
4838        mut prepared: PreparedBackupDestination,
4839        control: &crate::ExecutionControl,
4840        before_publish: F,
4841    ) -> Result<crate::backup::BackupReport>
4842    where
4843        F: FnOnce() -> bool,
4844    {
4845        let admin = crate::auth::Permission::Admin;
4846        self.require(&admin)?;
4847        let operation_principal = self.principal_snapshot();
4848        control.checkpoint()?;
4849        let destination = prepared.destination_path.clone();
4850        let mut before_publish = Some(before_publish);
4851
4852        let outcome = (|| {
4853            control.checkpoint()?;
4854            let barrier = self.replication_barrier.write();
4855            let ddl = self.ddl_lock.lock();
4856            let security = self.security_coordinator.gate.read();
4857            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4858            let mut handles: Vec<_> = self
4859                .tables
4860                .read()
4861                .iter()
4862                .map(|(id, handle)| (*id, handle.clone()))
4863                .collect();
4864            handles.sort_by_key(|(id, _)| *id);
4865            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4866            let commit = self.commit_lock.lock();
4867            let mut wal = self.shared_wal.lock();
4868            wal.group_sync()?;
4869            let epoch = self.epoch.committed().0;
4870            let boundary_unix_nanos = current_unix_nanos();
4871
4872            let pin_nonce = std::time::SystemTime::now()
4873                .duration_since(std::time::UNIX_EPOCH)
4874                .unwrap_or_default()
4875                .as_nanos();
4876            let file_pin_root = self
4877                .root
4878                .join(META_DIR)
4879                .join("backup-pins")
4880                .join(format!("{}-{pin_nonce}", std::process::id()));
4881            std::fs::create_dir_all(&file_pin_root)?;
4882            let _file_pins = BackupFilePins {
4883                root: file_pin_root.clone(),
4884            };
4885            let mut run_files = Vec::new();
4886            for (index, (table_id, _)) in handles.iter().enumerate() {
4887                if index % 256 == 0 {
4888                    control.checkpoint()?;
4889                }
4890                let table = &table_guards[index];
4891                for (run_index, run) in table.run_refs().iter().enumerate() {
4892                    if run_index % 256 == 0 {
4893                        control.checkpoint()?;
4894                    }
4895                    let source = table.run_path(run.run_id as u64);
4896                    let relative = Path::new(TABLES_DIR)
4897                        .join(table_id.to_string())
4898                        .join(crate::engine::RUNS_DIR)
4899                        .join(format!("r-{}.sr", run.run_id));
4900                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
4901                    if std::fs::hard_link(&source, &pinned).is_err() {
4902                        crate::backup::copy_file_synced(&source, &pinned)?;
4903                    }
4904                    run_files.push(((*table_id, run.run_id), pinned, relative));
4905                }
4906            }
4907            crate::durable_file::sync_directory(&file_pin_root)?;
4908            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
4909            {
4910                let mut pins = self.backup_pins.lock();
4911                for key in &run_keys {
4912                    *pins.entry(*key).or_insert(0) += 1;
4913                }
4914            }
4915            let _run_pins = RunPins {
4916                pins: Arc::clone(&self.backup_pins),
4917                runs: run_keys,
4918            };
4919            let deferred: HashSet<_> = run_files
4920                .iter()
4921                .map(|(_, _, relative)| relative.clone())
4922                .collect();
4923            let mut copied = Vec::new();
4924            copy_backup_boundary(
4925                &self.root,
4926                prepared.stage.as_deref().ok_or_else(|| {
4927                    MongrelError::Other("backup staging root was already released".into())
4928                })?,
4929                &deferred,
4930                &mut copied,
4931                Some(control),
4932            )?;
4933
4934            drop(wal);
4935            drop(commit);
4936            drop(table_guards);
4937            drop(security);
4938            drop(ddl);
4939            drop(barrier);
4940
4941            if let Some(hook) = self.backup_hook.lock().as_ref() {
4942                hook();
4943            }
4944            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
4945                if index % 256 == 0 {
4946                    control.checkpoint()?;
4947                }
4948                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
4949                prepared
4950                    .stage
4951                    .as_deref()
4952                    .ok_or_else(|| {
4953                        MongrelError::Other("backup staging root was already released".into())
4954                    })?
4955                    .copy_new_from(&relative, &mut source)?;
4956                copied.push(relative);
4957            }
4958
4959            let manifest = crate::backup::BackupManifest::create_controlled_durable(
4960                prepared.stage.as_deref().ok_or_else(|| {
4961                    MongrelError::Other("backup staging root was already released".into())
4962                })?,
4963                epoch,
4964                &copied,
4965                control,
4966            )?;
4967            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
4968                MongrelError::Other("backup staging root was already released".into())
4969            })?)?;
4970            control.checkpoint()?;
4971            let publish = before_publish.take().ok_or_else(|| {
4972                MongrelError::Other("backup publication callback already consumed".into())
4973            })?;
4974            if !publish() {
4975                return Err(MongrelError::Cancelled);
4976            }
4977            let final_security = self.security_coordinator.gate.read();
4978            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4979            // Windows pins directories without delete sharing. Release the
4980            // stage handle before renaming that directory, while the parent
4981            // remains descriptor-pinned for the no-replace publication.
4982            drop(prepared.stage.take().ok_or_else(|| {
4983                MongrelError::Other("backup staging root was already released".into())
4984            })?);
4985            let published = std::cell::Cell::new(false);
4986            if let Err(error) = prepared.parent.rename_directory_new_with_after(
4987                Path::new(&prepared.stage_name),
4988                &prepared.parent,
4989                Path::new(&prepared.destination_name),
4990                || published.set(true),
4991            ) {
4992                if published.get() {
4993                    return Err(MongrelError::CommitOutcomeUnknown {
4994                        epoch,
4995                        message: format!("backup publication was not durable: {error}"),
4996                    });
4997                }
4998                return Err(error.into());
4999            }
5000            drop(final_security);
5001            Ok(crate::backup::BackupReport {
5002                destination,
5003                epoch,
5004                boundary_unix_nanos,
5005                files: manifest.files.len(),
5006                bytes: manifest.total_bytes(),
5007            })
5008        })();
5009
5010        if outcome.is_err() {
5011            drop(prepared.stage.take());
5012            let _ = prepared
5013                .parent
5014                .remove_directory_all(Path::new(&prepared.stage_name));
5015        }
5016        outcome
5017    }
5018
5019    /// Return complete committed transactions after `since_epoch`. A gap or a
5020    /// transaction backed by a spilled run requires a fresh bootstrap image.
5021    pub fn replication_batch_since(
5022        &self,
5023        since_epoch: u64,
5024    ) -> Result<crate::replication::ReplicationBatch> {
5025        use crate::wal::Op;
5026
5027        let admin = crate::auth::Permission::Admin;
5028        self.require(&admin)?;
5029        let operation_principal = self.principal_snapshot();
5030
5031        let mut wal = self.shared_wal.lock();
5032        wal.group_sync()?;
5033        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
5034        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
5035        drop(wal);
5036
5037        let commits: HashMap<u64, u64> = records
5038            .iter()
5039            .filter_map(|record| match &record.op {
5040                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
5041                _ => None,
5042            })
5043            .collect();
5044        let earliest_epoch = commits.values().copied().min();
5045        let current_epoch = commits
5046            .values()
5047            .copied()
5048            .max()
5049            .unwrap_or(0)
5050            .max(self.epoch.committed().0);
5051        let selected: HashSet<u64> = commits
5052            .iter()
5053            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
5054            .collect();
5055        let retention_gap = since_epoch < current_epoch
5056            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
5057        let spilled = records.iter().any(|record| {
5058            selected.contains(&record.txn_id)
5059                && matches!(
5060                    &record.op,
5061                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
5062                )
5063        });
5064        let records = records
5065            .into_iter()
5066            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
5067            .filter(|record| selected.contains(&record.txn_id))
5068            .collect();
5069        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
5070        let batch = crate::replication::ReplicationBatch::complete_for_source(
5071            source_id,
5072            since_epoch,
5073            current_epoch,
5074            earliest_epoch,
5075            retention_gap,
5076            spilled,
5077            records,
5078        )?;
5079        if let Some(hook) = self.replication_hook.lock().as_ref() {
5080            hook();
5081        }
5082        let _security = self.security_coordinator.gate.read();
5083        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
5084        Ok(batch)
5085    }
5086
5087    /// Durably append a leader batch to a follower's local WAL and checkpoint
5088    /// its catalog metadata. Security changes apply to this live handle before
5089    /// success returns. The caller must reopen to mount new table state.
5090    pub fn append_replication_batch(
5091        &self,
5092        batch: &crate::replication::ReplicationBatch,
5093    ) -> Result<u64> {
5094        use crate::wal::Op;
5095
5096        if !self.read_only {
5097            return Err(MongrelError::InvalidArgument(
5098                "replication batches may only target a marked replica".into(),
5099            ));
5100        }
5101        let current = crate::replication::replica_epoch(&self.root)?;
5102        if batch.is_source_bound() {
5103            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
5104            if batch.source_id != source_id {
5105                return Err(MongrelError::Conflict(
5106                    "replication batch source does not match follower binding".into(),
5107                ));
5108            }
5109        }
5110        if batch.requires_snapshot {
5111            return Err(MongrelError::Conflict(
5112                "replication snapshot required for this batch".into(),
5113            ));
5114        }
5115        batch.validate_proof()?;
5116        if batch.from_epoch != current {
5117            if batch.from_epoch < current && batch.current_epoch == current {
5118                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
5119                let _wal = self.shared_wal.lock();
5120                let existing: HashSet<(u64, u64)> =
5121                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
5122                        .into_iter()
5123                        .filter_map(|record| match record.op {
5124                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
5125                            _ => None,
5126                        })
5127                        .collect();
5128                let already_applied = batch.records.iter().all(|record| match &record.op {
5129                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
5130                    _ => true,
5131                });
5132                if already_applied {
5133                    return Ok(current);
5134                }
5135            }
5136            return Err(MongrelError::Conflict(format!(
5137                "replication batch starts at epoch {}, follower is at epoch {current}",
5138                batch.from_epoch
5139            )));
5140        }
5141        if batch.current_epoch < current {
5142            return Err(MongrelError::InvalidArgument(format!(
5143                "replication batch current epoch {} precedes follower epoch {current}",
5144                batch.current_epoch
5145            )));
5146        }
5147        let records = &batch.records;
5148        let mut commits = HashMap::new();
5149        let mut commit_epochs = HashSet::new();
5150        let mut commit_timestamps = HashMap::new();
5151        for record in records {
5152            match &record.op {
5153                Op::TxnCommit { epoch, added_runs } => {
5154                    if !added_runs.is_empty() {
5155                        return Err(MongrelError::Conflict(
5156                            "replication snapshot required for spilled-run transaction".into(),
5157                        ));
5158                    }
5159                    if commits.insert(record.txn_id, *epoch).is_some() {
5160                        return Err(MongrelError::InvalidArgument(format!(
5161                            "duplicate commit for replication transaction {}",
5162                            record.txn_id
5163                        )));
5164                    }
5165                    if *epoch <= current || *epoch > batch.current_epoch {
5166                        return Err(MongrelError::InvalidArgument(format!(
5167                            "replication commit epoch {epoch} is outside ({current}, {}]",
5168                            batch.current_epoch
5169                        )));
5170                    }
5171                    if !commit_epochs.insert(*epoch) {
5172                        return Err(MongrelError::InvalidArgument(format!(
5173                            "duplicate replication commit epoch {epoch}"
5174                        )));
5175                    }
5176                }
5177                Op::CommitTimestamp { unix_nanos } => {
5178                    commit_timestamps.insert(record.txn_id, *unix_nanos);
5179                }
5180                _ => {}
5181            }
5182        }
5183        for record in records {
5184            if record.txn_id == crate::wal::SYSTEM_TXN_ID
5185                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
5186            {
5187                return Err(MongrelError::InvalidArgument(
5188                    "replication batch contains a non-committed record".into(),
5189                ));
5190            }
5191            if !commits.contains_key(&record.txn_id) {
5192                return Err(MongrelError::InvalidArgument(format!(
5193                    "incomplete replication transaction {}",
5194                    record.txn_id
5195                )));
5196            }
5197        }
5198        let target_epoch = commits
5199            .values()
5200            .copied()
5201            .filter(|epoch| *epoch > current)
5202            .max()
5203            .unwrap_or(current);
5204        if target_epoch != batch.current_epoch {
5205            return Err(MongrelError::InvalidArgument(format!(
5206                "replication batch ends at epoch {target_epoch}, expected {}",
5207                batch.current_epoch
5208            )));
5209        }
5210        let mut selected: HashSet<u64> = commits
5211            .iter()
5212            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
5213            .collect();
5214        if selected.is_empty() {
5215            return Ok(current);
5216        }
5217        let mut wal = self.shared_wal.lock();
5218        wal.group_sync()?;
5219        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
5220        let existing: HashSet<(u64, u64)> =
5221            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
5222                .into_iter()
5223                .filter_map(|record| match record.op {
5224                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
5225                    _ => None,
5226                })
5227                .collect();
5228        selected.retain(|txn_id| {
5229            commits
5230                .get(txn_id)
5231                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
5232        });
5233        for record in records {
5234            if !selected.contains(&record.txn_id) {
5235                continue;
5236            }
5237            match &record.op {
5238                Op::TxnCommit { epoch, added_runs } => {
5239                    let timestamp = commit_timestamps
5240                        .get(&record.txn_id)
5241                        .copied()
5242                        .unwrap_or_else(current_unix_nanos);
5243                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
5244                }
5245                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
5246                op => {
5247                    wal.append(record.txn_id, 0, op.clone())?;
5248                }
5249            }
5250        }
5251        if !selected.is_empty() {
5252            wal.group_sync()?;
5253        }
5254        drop(wal);
5255
5256        // Auth mode is selected before `finish_open` replays the WAL. Make the
5257        // catalog transition durable and publish security state to this live
5258        // handle before reporting success.
5259        let mut recovered_catalog = self.catalog.read().clone();
5260        if let Err(error) = recover_ddl_from_wal(
5261            &self.root,
5262            Some(&self.durable_root),
5263            &mut recovered_catalog,
5264            self.meta_dek.as_ref(),
5265            wal_dek.as_ref(),
5266            true,
5267            None,
5268        ) {
5269            return Err(MongrelError::DurableCommit {
5270                epoch: target_epoch,
5271                message: format!(
5272                    "replication WAL is durable but catalog checkpoint failed: {error}"
5273                ),
5274            });
5275        }
5276        let _security = self.security_coordinator.gate.write();
5277        let old_security_version = self.catalog.read().security_version;
5278        let security_changed = old_security_version != recovered_catalog.security_version
5279            || self.catalog.read().require_auth != recovered_catalog.require_auth;
5280        let require_auth = recovered_catalog.require_auth;
5281        let principal = if security_changed {
5282            None
5283        } else {
5284            self.principal.read().as_ref().and_then(|principal| {
5285                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
5286            })
5287        };
5288        if require_auth {
5289            self.auth_state.set_require_auth(true);
5290        }
5291        self.auth_state.set_principal(principal.clone());
5292        *self.principal.write() = principal;
5293        let security_version = recovered_catalog.security_version;
5294        *self.catalog.write() = recovered_catalog;
5295        self.security_coordinator
5296            .version
5297            .store(security_version, Ordering::Release);
5298        if !require_auth {
5299            self.auth_state.set_require_auth(false);
5300        }
5301        if let Err(error) =
5302            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
5303        {
5304            return Err(MongrelError::DurableCommit {
5305                epoch: target_epoch,
5306                message: format!(
5307                    "replication WAL and catalog are durable but follower watermark failed: {error}"
5308                ),
5309            });
5310        }
5311        Ok(target_epoch)
5312    }
5313
5314    /// Resolve a table name → id (live tables only). pub(crate) so the
5315    /// transaction layer can stage by name.
5316    pub fn table_id(&self, name: &str) -> Result<u64> {
5317        let cat = self.catalog.read();
5318        cat.live(name)
5319            .map(|e| e.table_id)
5320            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
5321    }
5322
5323    /// Return the stable table id and current schema generation from one
5324    /// catalog snapshot. Callers can bind retries to this identity so a table
5325    /// dropped and recreated under the same name is never mistaken for the
5326    /// original resource.
5327    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
5328        let catalog = self.catalog.read();
5329        catalog
5330            .live(name)
5331            .map(|entry| (entry.table_id, entry.schema.schema_id))
5332            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
5333    }
5334
5335    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
5336        self.catalog
5337            .read()
5338            .building(name)
5339            .map(|entry| entry.table_id)
5340            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
5341    }
5342
5343    pub fn procedures(&self) -> Vec<StoredProcedure> {
5344        self.catalog
5345            .read()
5346            .procedures
5347            .iter()
5348            .map(|p| p.procedure.clone())
5349            .collect()
5350    }
5351
5352    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
5353        self.catalog
5354            .read()
5355            .procedures
5356            .iter()
5357            .find(|p| p.procedure.name == name)
5358            .map(|p| p.procedure.clone())
5359    }
5360
5361    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
5362        self.create_procedure_inner(procedure, None)
5363    }
5364
5365    pub fn create_procedure_controlled<F>(
5366        &self,
5367        procedure: StoredProcedure,
5368        mut before_publish: F,
5369    ) -> Result<StoredProcedure>
5370    where
5371        F: FnMut() -> Result<()>,
5372    {
5373        self.create_procedure_inner(procedure, Some(&mut before_publish))
5374    }
5375
5376    fn create_procedure_inner(
5377        &self,
5378        mut procedure: StoredProcedure,
5379        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5380    ) -> Result<StoredProcedure> {
5381        self.require(&crate::auth::Permission::Ddl)?;
5382        let _g = self.ddl_lock.lock();
5383        let _security_write = self.security_write()?;
5384        self.require(&crate::auth::Permission::Ddl)?;
5385        procedure.validate()?;
5386        self.validate_procedure_references(&procedure)?;
5387        {
5388            let cat = self.catalog.read();
5389            if cat
5390                .procedures
5391                .iter()
5392                .any(|p| p.procedure.name == procedure.name)
5393            {
5394                return Err(MongrelError::InvalidArgument(format!(
5395                    "procedure {:?} already exists",
5396                    procedure.name
5397                )));
5398            }
5399        }
5400        let commit_lock = Arc::clone(&self.commit_lock);
5401        let _c = commit_lock.lock();
5402        let epoch = self.epoch.bump_assigned();
5403        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5404        procedure.created_epoch = epoch.0;
5405        procedure.updated_epoch = epoch.0;
5406        let mut next_catalog = self.catalog.read().clone();
5407        next_catalog
5408            .procedures
5409            .push(ProcedureEntry::from(procedure.clone()));
5410        next_catalog.db_epoch = epoch.0;
5411        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5412        Ok(procedure)
5413    }
5414
5415    pub fn create_or_replace_procedure(
5416        &self,
5417        procedure: StoredProcedure,
5418    ) -> Result<StoredProcedure> {
5419        self.create_or_replace_procedure_inner(procedure, None)
5420    }
5421
5422    pub fn create_or_replace_procedure_controlled<F>(
5423        &self,
5424        procedure: StoredProcedure,
5425        mut before_publish: F,
5426    ) -> Result<StoredProcedure>
5427    where
5428        F: FnMut() -> Result<()>,
5429    {
5430        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
5431    }
5432
5433    fn create_or_replace_procedure_inner(
5434        &self,
5435        procedure: StoredProcedure,
5436        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5437    ) -> Result<StoredProcedure> {
5438        self.require(&crate::auth::Permission::Ddl)?;
5439        let _g = self.ddl_lock.lock();
5440        let _security_write = self.security_write()?;
5441        self.require(&crate::auth::Permission::Ddl)?;
5442        procedure.validate()?;
5443        self.validate_procedure_references(&procedure)?;
5444        let commit_lock = Arc::clone(&self.commit_lock);
5445        let _c = commit_lock.lock();
5446        let epoch = self.epoch.bump_assigned();
5447        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5448        let mut next_catalog = self.catalog.read().clone();
5449        let replaced = {
5450            let next = match next_catalog
5451                .procedures
5452                .iter()
5453                .position(|p| p.procedure.name == procedure.name)
5454            {
5455                Some(idx) => {
5456                    let next = next_catalog.procedures[idx]
5457                        .procedure
5458                        .replaced(procedure.clone(), epoch.0)?;
5459                    next_catalog.procedures[idx] = ProcedureEntry::from(next.clone());
5460                    next
5461                }
5462                None => {
5463                    let mut next = procedure;
5464                    next.created_epoch = epoch.0;
5465                    next.updated_epoch = epoch.0;
5466                    next_catalog
5467                        .procedures
5468                        .push(ProcedureEntry::from(next.clone()));
5469                    next
5470                }
5471            };
5472            next_catalog.db_epoch = epoch.0;
5473            next
5474        };
5475        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5476        Ok(replaced)
5477    }
5478
5479    pub fn drop_procedure(&self, name: &str) -> Result<()> {
5480        self.drop_procedure_with_epoch(name).map(|_| ())
5481    }
5482
5483    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
5484        self.drop_procedure_with_epoch_inner(name, None)
5485    }
5486
5487    pub fn drop_procedure_with_epoch_controlled<F>(
5488        &self,
5489        name: &str,
5490        mut before_publish: F,
5491    ) -> Result<Epoch>
5492    where
5493        F: FnMut() -> Result<()>,
5494    {
5495        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
5496    }
5497
5498    fn drop_procedure_with_epoch_inner(
5499        &self,
5500        name: &str,
5501        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5502    ) -> Result<Epoch> {
5503        self.require(&crate::auth::Permission::Ddl)?;
5504        let _g = self.ddl_lock.lock();
5505        let _security_write = self.security_write()?;
5506        self.require(&crate::auth::Permission::Ddl)?;
5507        let commit_lock = Arc::clone(&self.commit_lock);
5508        let _c = commit_lock.lock();
5509        let epoch = self.epoch.bump_assigned();
5510        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5511        let mut next_catalog = self.catalog.read().clone();
5512        let before = next_catalog.procedures.len();
5513        next_catalog.procedures.retain(|p| p.procedure.name != name);
5514        if next_catalog.procedures.len() == before {
5515            return Err(MongrelError::NotFound(format!(
5516                "procedure {name:?} not found"
5517            )));
5518        }
5519        next_catalog.db_epoch = epoch.0;
5520        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5521        Ok(epoch)
5522    }
5523
5524    // ── User / role / credentials management ─────────────────────────────
5525
5526    /// List all catalog users (password hashes included — callers should not
5527    /// serialize them externally).
5528    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
5529        self.catalog.read().users.clone()
5530    }
5531
5532    /// Resolve only the stable, non-secret identity fields needed to scope
5533    /// request receipts. Password hashes never leave the catalog lock.
5534    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
5535        self.catalog
5536            .read()
5537            .users
5538            .iter()
5539            .find(|user| user.username == username)
5540            .map(|user| (user.id, user.created_epoch))
5541    }
5542
5543    /// Current catalog authorization generation. Retry bindings can include
5544    /// this value to fail closed after roles, grants, or row policies change.
5545    pub fn security_version(&self) -> u64 {
5546        self.catalog.read().security_version
5547    }
5548
5549    /// List all catalog roles.
5550    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
5551        self.catalog.read().roles.clone()
5552    }
5553
5554    /// Create a new user with an Argon2id-hashed password.
5555    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
5556        self.require(&crate::auth::Permission::Admin)?;
5557        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
5558        self.create_user_with_password_hash(username, hash)
5559    }
5560
5561    /// Create a user from a password hash prepared before a commit fence.
5562    pub fn create_user_with_password_hash(
5563        &self,
5564        username: &str,
5565        hash: String,
5566    ) -> Result<crate::auth::UserEntry> {
5567        self.create_user_with_password_hash_inner(username, hash, None)
5568    }
5569
5570    pub fn create_user_with_password_hash_controlled<F>(
5571        &self,
5572        username: &str,
5573        hash: String,
5574        mut before_publish: F,
5575    ) -> Result<crate::auth::UserEntry>
5576    where
5577        F: FnMut() -> Result<()>,
5578    {
5579        self.create_user_with_password_hash_inner(username, hash, Some(&mut before_publish))
5580    }
5581
5582    fn create_user_with_password_hash_inner(
5583        &self,
5584        username: &str,
5585        hash: String,
5586        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5587    ) -> Result<crate::auth::UserEntry> {
5588        self.require(&crate::auth::Permission::Admin)?;
5589        let _ddl = self.ddl_lock.lock();
5590        let _security_write = self.security_write()?;
5591        self.require(&crate::auth::Permission::Admin)?;
5592        let _commit = self.commit_lock.lock();
5593        let epoch = self.epoch.bump_assigned();
5594        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5595        let mut next_catalog = self.catalog.read().clone();
5596        if next_catalog.users.iter().any(|u| u.username == username) {
5597            return Err(MongrelError::InvalidArgument(format!(
5598                "user {username:?} already exists"
5599            )));
5600        }
5601        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
5602        let id = next_catalog.next_user_id;
5603        next_catalog.next_user_id = id
5604            .checked_add(1)
5605            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
5606        let entry = crate::auth::UserEntry {
5607            id,
5608            username: username.into(),
5609            password_hash: hash,
5610            roles: Vec::new(),
5611            is_admin: false,
5612            created_epoch: epoch.0,
5613        };
5614        next_catalog.users.push(entry.clone());
5615        advance_security_version(&mut next_catalog)?;
5616        next_catalog.db_epoch = epoch.0;
5617        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5618        Ok(entry)
5619    }
5620
5621    /// Drop a user by username.
5622    pub fn drop_user(&self, username: &str) -> Result<()> {
5623        self.drop_user_with_epoch(username).map(|_| ())
5624    }
5625
5626    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
5627        self.drop_user_with_epoch_inner(username, None)
5628    }
5629
5630    pub fn drop_user_with_epoch_controlled<F>(
5631        &self,
5632        username: &str,
5633        mut before_publish: F,
5634    ) -> Result<Epoch>
5635    where
5636        F: FnMut() -> Result<()>,
5637    {
5638        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
5639    }
5640
5641    fn drop_user_with_epoch_inner(
5642        &self,
5643        username: &str,
5644        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5645    ) -> Result<Epoch> {
5646        self.require(&crate::auth::Permission::Admin)?;
5647        let _ddl = self.ddl_lock.lock();
5648        let _security_write = self.security_write()?;
5649        self.require(&crate::auth::Permission::Admin)?;
5650        let _commit = self.commit_lock.lock();
5651        let epoch = self.epoch.bump_assigned();
5652        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5653        let mut next_catalog = self.catalog.read().clone();
5654        let before = next_catalog.users.len();
5655        next_catalog.users.retain(|u| u.username != username);
5656        if next_catalog.users.len() == before {
5657            return Err(MongrelError::NotFound(format!(
5658                "user {username:?} not found"
5659            )));
5660        }
5661        advance_security_version(&mut next_catalog)?;
5662        next_catalog.db_epoch = epoch.0;
5663        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5664        Ok(epoch)
5665    }
5666
5667    /// Change a user's password.
5668    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
5669        self.alter_user_password_with_epoch(username, new_password)
5670            .map(|_| ())
5671    }
5672
5673    pub fn alter_user_password_with_epoch(
5674        &self,
5675        username: &str,
5676        new_password: &str,
5677    ) -> Result<Epoch> {
5678        self.require(&crate::auth::Permission::Admin)?;
5679        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
5680        self.alter_user_password_hash_with_epoch(username, hash)
5681    }
5682
5683    pub fn alter_user_password_hash_with_epoch(
5684        &self,
5685        username: &str,
5686        hash: String,
5687    ) -> Result<Epoch> {
5688        self.alter_user_password_hash_with_epoch_inner(username, hash, None)
5689    }
5690
5691    pub fn alter_user_password_hash_with_epoch_controlled<F>(
5692        &self,
5693        username: &str,
5694        hash: String,
5695        mut before_publish: F,
5696    ) -> Result<Epoch>
5697    where
5698        F: FnMut() -> Result<()>,
5699    {
5700        self.alter_user_password_hash_with_epoch_inner(username, hash, Some(&mut before_publish))
5701    }
5702
5703    fn alter_user_password_hash_with_epoch_inner(
5704        &self,
5705        username: &str,
5706        hash: String,
5707        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5708    ) -> Result<Epoch> {
5709        self.require(&crate::auth::Permission::Admin)?;
5710        let _ddl = self.ddl_lock.lock();
5711        let _security_write = self.security_write()?;
5712        self.require(&crate::auth::Permission::Admin)?;
5713        let _commit = self.commit_lock.lock();
5714        let epoch = self.epoch.bump_assigned();
5715        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5716        let mut next_catalog = self.catalog.read().clone();
5717        let user = next_catalog
5718            .users
5719            .iter_mut()
5720            .find(|u| u.username == username)
5721            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5722        user.password_hash = hash;
5723        advance_security_version(&mut next_catalog)?;
5724        next_catalog.db_epoch = epoch.0;
5725        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5726        Ok(epoch)
5727    }
5728
5729    /// Verify credentials. Returns `Some(entry)` on success, `None` on
5730    /// mismatch, `Err` on engine error.
5731    pub fn verify_user(
5732        &self,
5733        username: &str,
5734        password: &str,
5735    ) -> Result<Option<crate::auth::UserEntry>> {
5736        let cat = self.catalog.read();
5737        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
5738            return Ok(None);
5739        };
5740        if user.password_hash.is_empty() {
5741            return Ok(None);
5742        }
5743        let ok = crate::auth::verify_password(password, &user.password_hash)
5744            .map_err(MongrelError::Other)?;
5745        if ok {
5746            Ok(Some(user.clone()))
5747        } else {
5748            Ok(None)
5749        }
5750    }
5751
5752    /// Authenticate and resolve one immutable principal from the same catalog
5753    /// snapshot. Username reuse cannot bridge the password check and principal
5754    /// resolution.
5755    pub fn authenticate_principal(
5756        &self,
5757        username: &str,
5758        password: &str,
5759    ) -> Result<Option<crate::auth::Principal>> {
5760        self.authenticate_principal_inner(username, password, || {})
5761    }
5762
5763    fn authenticate_principal_inner<F>(
5764        &self,
5765        username: &str,
5766        password: &str,
5767        after_verify: F,
5768    ) -> Result<Option<crate::auth::Principal>>
5769    where
5770        F: FnOnce(),
5771    {
5772        let catalog = self.catalog.read();
5773        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
5774            return Ok(None);
5775        };
5776        if user.password_hash.is_empty()
5777            || !crate::auth::verify_password(password, &user.password_hash)
5778                .map_err(MongrelError::Other)?
5779        {
5780            return Ok(None);
5781        }
5782        after_verify();
5783        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
5784    }
5785
5786    /// Grant admin privileges to a user (bypasses all permission checks).
5787    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
5788        self.set_user_admin_with_epoch(username, is_admin)
5789            .map(|_| ())
5790    }
5791
5792    pub fn set_user_admin_with_epoch(
5793        &self,
5794        username: &str,
5795        is_admin: bool,
5796    ) -> Result<Option<Epoch>> {
5797        self.set_user_admin_with_epoch_inner(username, is_admin, None)
5798    }
5799
5800    pub fn set_user_admin_with_epoch_controlled<F>(
5801        &self,
5802        username: &str,
5803        is_admin: bool,
5804        mut before_publish: F,
5805    ) -> Result<Option<Epoch>>
5806    where
5807        F: FnMut() -> Result<()>,
5808    {
5809        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
5810    }
5811
5812    fn set_user_admin_with_epoch_inner(
5813        &self,
5814        username: &str,
5815        is_admin: bool,
5816        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5817    ) -> Result<Option<Epoch>> {
5818        self.require(&crate::auth::Permission::Admin)?;
5819        let _ddl = self.ddl_lock.lock();
5820        let _security_write = self.security_write()?;
5821        self.require(&crate::auth::Permission::Admin)?;
5822        let _commit = self.commit_lock.lock();
5823        let mut next_catalog = self.catalog.read().clone();
5824        let user = next_catalog
5825            .users
5826            .iter_mut()
5827            .find(|u| u.username == username)
5828            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5829        if user.is_admin == is_admin {
5830            return Ok(None);
5831        }
5832        user.is_admin = is_admin;
5833        let epoch = self.epoch.bump_assigned();
5834        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5835        advance_security_version(&mut next_catalog)?;
5836        next_catalog.db_epoch = epoch.0;
5837        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5838        Ok(Some(epoch))
5839    }
5840
5841    /// Create a new role.
5842    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
5843        self.create_role_inner(name, None)
5844    }
5845
5846    pub fn create_role_controlled<F>(
5847        &self,
5848        name: &str,
5849        mut before_publish: F,
5850    ) -> Result<crate::auth::RoleEntry>
5851    where
5852        F: FnMut() -> Result<()>,
5853    {
5854        self.create_role_inner(name, Some(&mut before_publish))
5855    }
5856
5857    fn create_role_inner(
5858        &self,
5859        name: &str,
5860        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5861    ) -> Result<crate::auth::RoleEntry> {
5862        self.require(&crate::auth::Permission::Admin)?;
5863        let _ddl = self.ddl_lock.lock();
5864        let _security_write = self.security_write()?;
5865        self.require(&crate::auth::Permission::Admin)?;
5866        let _commit = self.commit_lock.lock();
5867        let epoch = self.epoch.bump_assigned();
5868        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5869        let mut next_catalog = self.catalog.read().clone();
5870        if next_catalog.roles.iter().any(|r| r.name == name) {
5871            return Err(MongrelError::InvalidArgument(format!(
5872                "role {name:?} already exists"
5873            )));
5874        }
5875        let entry = crate::auth::RoleEntry {
5876            name: name.into(),
5877            permissions: Vec::new(),
5878            created_epoch: epoch.0,
5879        };
5880        next_catalog.roles.push(entry.clone());
5881        advance_security_version(&mut next_catalog)?;
5882        next_catalog.db_epoch = epoch.0;
5883        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5884        Ok(entry)
5885    }
5886
5887    /// Drop a role by name.
5888    pub fn drop_role(&self, name: &str) -> Result<()> {
5889        self.drop_role_with_epoch(name).map(|_| ())
5890    }
5891
5892    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
5893        self.drop_role_with_epoch_inner(name, None)
5894    }
5895
5896    pub fn drop_role_with_epoch_controlled<F>(
5897        &self,
5898        name: &str,
5899        mut before_publish: F,
5900    ) -> Result<Epoch>
5901    where
5902        F: FnMut() -> Result<()>,
5903    {
5904        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
5905    }
5906
5907    fn drop_role_with_epoch_inner(
5908        &self,
5909        name: &str,
5910        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5911    ) -> Result<Epoch> {
5912        self.require(&crate::auth::Permission::Admin)?;
5913        let _ddl = self.ddl_lock.lock();
5914        let _security_write = self.security_write()?;
5915        self.require(&crate::auth::Permission::Admin)?;
5916        let _commit = self.commit_lock.lock();
5917        let epoch = self.epoch.bump_assigned();
5918        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5919        let mut next_catalog = self.catalog.read().clone();
5920        let before = next_catalog.roles.len();
5921        next_catalog.roles.retain(|r| r.name != name);
5922        if next_catalog.roles.len() == before {
5923            return Err(MongrelError::NotFound(format!("role {name:?} not found")));
5924        }
5925        for user in &mut next_catalog.users {
5926            user.roles.retain(|r| r != name);
5927        }
5928        advance_security_version(&mut next_catalog)?;
5929        next_catalog.db_epoch = epoch.0;
5930        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5931        Ok(epoch)
5932    }
5933
5934    /// Grant a role to a user.
5935    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
5936        self.grant_role_with_epoch(username, role_name).map(|_| ())
5937    }
5938
5939    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5940        self.grant_role_with_epoch_inner(username, role_name, None)
5941    }
5942
5943    pub fn grant_role_with_epoch_controlled<F>(
5944        &self,
5945        username: &str,
5946        role_name: &str,
5947        mut before_publish: F,
5948    ) -> Result<Option<Epoch>>
5949    where
5950        F: FnMut() -> Result<()>,
5951    {
5952        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5953    }
5954
5955    fn grant_role_with_epoch_inner(
5956        &self,
5957        username: &str,
5958        role_name: &str,
5959        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5960    ) -> Result<Option<Epoch>> {
5961        self.require(&crate::auth::Permission::Admin)?;
5962        let _ddl = self.ddl_lock.lock();
5963        let _security_write = self.security_write()?;
5964        self.require(&crate::auth::Permission::Admin)?;
5965        let _commit = self.commit_lock.lock();
5966        let mut next_catalog = self.catalog.read().clone();
5967        if !next_catalog.roles.iter().any(|r| r.name == role_name) {
5968            return Err(MongrelError::NotFound(format!(
5969                "role {role_name:?} not found"
5970            )));
5971        }
5972        let user = next_catalog
5973            .users
5974            .iter_mut()
5975            .find(|u| u.username == username)
5976            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5977        if user.roles.iter().any(|role| role == role_name) {
5978            return Ok(None);
5979        }
5980        user.roles.push(role_name.into());
5981        let epoch = self.epoch.bump_assigned();
5982        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5983        advance_security_version(&mut next_catalog)?;
5984        next_catalog.db_epoch = epoch.0;
5985        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5986        Ok(Some(epoch))
5987    }
5988
5989    /// Revoke a role from a user.
5990    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
5991        self.revoke_role_with_epoch(username, role_name).map(|_| ())
5992    }
5993
5994    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5995        self.revoke_role_with_epoch_inner(username, role_name, None)
5996    }
5997
5998    pub fn revoke_role_with_epoch_controlled<F>(
5999        &self,
6000        username: &str,
6001        role_name: &str,
6002        mut before_publish: F,
6003    ) -> Result<Option<Epoch>>
6004    where
6005        F: FnMut() -> Result<()>,
6006    {
6007        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
6008    }
6009
6010    fn revoke_role_with_epoch_inner(
6011        &self,
6012        username: &str,
6013        role_name: &str,
6014        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6015    ) -> Result<Option<Epoch>> {
6016        self.require(&crate::auth::Permission::Admin)?;
6017        let _ddl = self.ddl_lock.lock();
6018        let _security_write = self.security_write()?;
6019        self.require(&crate::auth::Permission::Admin)?;
6020        let _commit = self.commit_lock.lock();
6021        let mut next_catalog = self.catalog.read().clone();
6022        let user = next_catalog
6023            .users
6024            .iter_mut()
6025            .find(|u| u.username == username)
6026            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
6027        let before = user.roles.len();
6028        user.roles.retain(|r| r != role_name);
6029        if user.roles.len() == before {
6030            return Ok(None);
6031        }
6032        let epoch = self.epoch.bump_assigned();
6033        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6034        advance_security_version(&mut next_catalog)?;
6035        next_catalog.db_epoch = epoch.0;
6036        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6037        Ok(Some(epoch))
6038    }
6039
6040    /// Grant a permission to a role.
6041    pub fn grant_permission(
6042        &self,
6043        role_name: &str,
6044        permission: crate::auth::Permission,
6045    ) -> Result<()> {
6046        self.grant_permission_with_epoch(role_name, permission)
6047            .map(|_| ())
6048    }
6049
6050    pub fn grant_permission_with_epoch(
6051        &self,
6052        role_name: &str,
6053        permission: crate::auth::Permission,
6054    ) -> Result<Option<Epoch>> {
6055        self.grant_permission_with_epoch_inner(role_name, permission, None)
6056    }
6057
6058    pub fn grant_permission_with_epoch_controlled<F>(
6059        &self,
6060        role_name: &str,
6061        permission: crate::auth::Permission,
6062        mut before_publish: F,
6063    ) -> Result<Option<Epoch>>
6064    where
6065        F: FnMut() -> Result<()>,
6066    {
6067        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
6068    }
6069
6070    fn grant_permission_with_epoch_inner(
6071        &self,
6072        role_name: &str,
6073        permission: crate::auth::Permission,
6074        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6075    ) -> Result<Option<Epoch>> {
6076        self.require(&crate::auth::Permission::Admin)?;
6077        let _ddl = self.ddl_lock.lock();
6078        let _security_write = self.security_write()?;
6079        self.require(&crate::auth::Permission::Admin)?;
6080        let _commit = self.commit_lock.lock();
6081        let mut next_catalog = self.catalog.read().clone();
6082        let role = next_catalog
6083            .roles
6084            .iter_mut()
6085            .find(|r| r.name == role_name)
6086            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
6087        let before = role.permissions.clone();
6088        merge_permission(&mut role.permissions, permission);
6089        if role.permissions == before {
6090            return Ok(None);
6091        }
6092        let epoch = self.epoch.bump_assigned();
6093        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6094        advance_security_version(&mut next_catalog)?;
6095        next_catalog.db_epoch = epoch.0;
6096        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6097        Ok(Some(epoch))
6098    }
6099
6100    /// Revoke a permission from a role.
6101    pub fn revoke_permission(
6102        &self,
6103        role_name: &str,
6104        permission: crate::auth::Permission,
6105    ) -> Result<()> {
6106        self.revoke_permission_with_epoch(role_name, permission)
6107            .map(|_| ())
6108    }
6109
6110    pub fn revoke_permission_with_epoch(
6111        &self,
6112        role_name: &str,
6113        permission: crate::auth::Permission,
6114    ) -> Result<Option<Epoch>> {
6115        self.revoke_permission_with_epoch_inner(role_name, permission, None)
6116    }
6117
6118    pub fn revoke_permission_with_epoch_controlled<F>(
6119        &self,
6120        role_name: &str,
6121        permission: crate::auth::Permission,
6122        mut before_publish: F,
6123    ) -> Result<Option<Epoch>>
6124    where
6125        F: FnMut() -> Result<()>,
6126    {
6127        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
6128    }
6129
6130    fn revoke_permission_with_epoch_inner(
6131        &self,
6132        role_name: &str,
6133        permission: crate::auth::Permission,
6134        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6135    ) -> Result<Option<Epoch>> {
6136        self.require(&crate::auth::Permission::Admin)?;
6137        let _ddl = self.ddl_lock.lock();
6138        let _security_write = self.security_write()?;
6139        self.require(&crate::auth::Permission::Admin)?;
6140        let _commit = self.commit_lock.lock();
6141        let mut next_catalog = self.catalog.read().clone();
6142        let role = next_catalog
6143            .roles
6144            .iter_mut()
6145            .find(|r| r.name == role_name)
6146            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
6147        let before = role.permissions.clone();
6148        revoke_permission_from(&mut role.permissions, &permission);
6149        if role.permissions == before {
6150            return Ok(None);
6151        }
6152        let epoch = self.epoch.bump_assigned();
6153        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6154        advance_security_version(&mut next_catalog)?;
6155        next_catalog.db_epoch = epoch.0;
6156        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6157        Ok(Some(epoch))
6158    }
6159
6160    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
6161    /// permissions from their roles. Returns `None` if the user doesn't exist.
6162    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
6163        let cat = self.catalog.read();
6164        Self::resolve_principal_from_catalog(&cat, username)
6165    }
6166
6167    /// Re-resolve only when the immutable user identity still exists. This is
6168    /// the server/session validation path; username reuse never matches.
6169    pub fn resolve_current_principal(
6170        &self,
6171        principal: &crate::auth::Principal,
6172    ) -> Option<crate::auth::Principal> {
6173        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
6174    }
6175
6176    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
6177    /// without needing a constructed `Database`. Used by the credentialed open
6178    /// path (which must verify credentials before the `Database` exists) and
6179    /// by [`resolve_principal`](Self::resolve_principal).
6180    fn resolve_principal_from_catalog(
6181        cat: &Catalog,
6182        username: &str,
6183    ) -> Option<crate::auth::Principal> {
6184        let user = cat.users.iter().find(|u| u.username == username)?;
6185        Self::resolve_user_principal_from_catalog(cat, user)
6186    }
6187
6188    fn resolve_bound_principal_from_catalog(
6189        cat: &Catalog,
6190        principal: &crate::auth::Principal,
6191    ) -> Option<crate::auth::Principal> {
6192        let user = cat.users.iter().find(|user| {
6193            user.id == principal.user_id
6194                && user.created_epoch == principal.created_epoch
6195                && user.username == principal.username
6196        })?;
6197        Self::resolve_user_principal_from_catalog(cat, user)
6198    }
6199
6200    fn resolve_user_principal_from_catalog(
6201        cat: &Catalog,
6202        user: &crate::auth::UserEntry,
6203    ) -> Option<crate::auth::Principal> {
6204        let mut permissions = Vec::new();
6205        for role_name in &user.roles {
6206            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
6207                permissions.extend(role.permissions.iter().cloned());
6208            }
6209        }
6210        Some(crate::auth::Principal {
6211            user_id: user.id,
6212            created_epoch: user.created_epoch,
6213            username: user.username.clone(),
6214            is_admin: user.is_admin,
6215            roles: user.roles.clone(),
6216            permissions,
6217        })
6218    }
6219
6220    /// Check whether a user has a specific permission (via their roles).
6221    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
6222        match self.resolve_principal(username) {
6223            Some(p) => p.has_permission(permission),
6224            None => false,
6225        }
6226    }
6227
6228    /// Returns `true` if this database's catalog has `require_auth = true`.
6229    /// When true, every operation consults the cached [`Principal`] via
6230    /// [`require`](Self::require).
6231    pub fn require_auth_enabled(&self) -> bool {
6232        self.catalog.read().require_auth
6233    }
6234
6235    /// A snapshot of the cached principal for this handle, if any. `None` for
6236    /// databases opened without credentials (the default). Returns a clone
6237    /// because the principal lives behind an `RwLock`.
6238    pub fn principal(&self) -> Option<crate::auth::Principal> {
6239        self.principal.read().clone()
6240    }
6241
6242    /// Build a `TableAuthChecker` from the current auth state. Used when
6243    /// mounting a new table (`create_table`) so the table inherits the
6244    /// database's enforcement configuration. The checker reads the live
6245    /// `require_auth` flag and cached principal, so changes via `enable_auth`
6246    /// / `refresh_principal` propagate to already-mounted tables.
6247    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
6248        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
6249            self.auth_state.clone(),
6250        )))
6251    }
6252
6253    /// Re-resolve the cached principal from the shared current catalog.
6254    /// Long-lived
6255    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
6256    /// possibly made by a different handle to the same database — to pick up
6257    /// the new effective permissions without re-verifying the password.
6258    ///
6259    /// The process-wide security version reloads from disk only when another
6260    /// handle published a newer catalog. The username is taken from
6261    /// the existing cached principal; if the user has since been dropped,
6262    /// returns [`MongrelError::InvalidCredentials`].
6263    ///
6264    /// No-op (returns `Ok(())`) on a credentialless database, or on a
6265    /// credentialed database whose cached principal is `None`.
6266    pub fn refresh_principal(&self) -> Result<()> {
6267        let previous = match self.principal.read().clone() {
6268            Some(principal) => principal,
6269            None => return Ok(()),
6270        };
6271        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
6272        self.refresh_security_catalog_if_stale(observed_version)?;
6273        let cat = self.catalog.read();
6274        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
6275            Some(p) => {
6276                *self.principal.write() = Some(p.clone());
6277                // Update the shared auth state so mounted Tables see the new
6278                // permissions immediately (Tables read from AuthState, not from
6279                // self.principal).
6280                self.auth_state.set_principal(Some(p));
6281                Ok(())
6282            }
6283            None => Err(MongrelError::InvalidCredentials {
6284                username: previous.username,
6285            }),
6286        }
6287    }
6288
6289    /// Number of security-catalog disk reloads performed by this open handle.
6290    /// Initial open reads are excluded.
6291    pub fn security_catalog_disk_read_count(&self) -> u64 {
6292        self.security_catalog_disk_reads.load(Ordering::Relaxed)
6293    }
6294
6295    /// Convert a credentialless database to a credentialed one: create the
6296    /// first admin user, set `require_auth = true`, and cache the admin
6297    /// principal on this handle so subsequent operations on the same handle
6298    /// continue to work. After this call, the database can only be reopened
6299    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
6300    ///
6301    /// Refuses if the database already has `require_auth = true`. This is
6302    /// the conversion path for existing databases; for fresh databases,
6303    /// `create_with_credentials` sets everything up atomically.
6304    ///
6305    /// See `docs/15-credential-enforcement.md`.
6306    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
6307        let password_hash =
6308            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
6309        let _ddl = self.ddl_lock.lock();
6310        let _security_write = self.security_write()?;
6311        let _commit = self.commit_lock.lock();
6312        let epoch = self.epoch.bump_assigned();
6313        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6314        let mut next_catalog = self.catalog.read().clone();
6315        if next_catalog.require_auth {
6316            return Err(MongrelError::InvalidArgument(
6317                "database already has require_auth enabled".into(),
6318            ));
6319        }
6320        if next_catalog
6321            .users
6322            .iter()
6323            .any(|u| u.username == admin_username)
6324        {
6325            return Err(MongrelError::InvalidArgument(format!(
6326                "user {admin_username:?} already exists"
6327            )));
6328        }
6329        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
6330        let id = next_catalog.next_user_id;
6331        next_catalog.next_user_id = id
6332            .checked_add(1)
6333            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
6334        next_catalog.users.push(crate::auth::UserEntry {
6335            id,
6336            username: admin_username.to_string(),
6337            password_hash,
6338            roles: Vec::new(),
6339            is_admin: true,
6340            created_epoch: epoch.0,
6341        });
6342        next_catalog.require_auth = true;
6343        advance_security_version(&mut next_catalog)?;
6344        next_catalog.db_epoch = epoch.0;
6345        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6346        // Cache the admin principal on this handle + update the shared auth
6347        // state whenever rename published, even if directory fsync was
6348        // inconclusive.
6349        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6350            let principal = crate::auth::Principal {
6351                user_id: id,
6352                created_epoch: epoch.0,
6353                username: admin_username.to_string(),
6354                is_admin: true,
6355                roles: Vec::new(),
6356                permissions: Vec::new(),
6357            };
6358            *self.principal.write() = Some(principal.clone());
6359            self.auth_state.set_principal(Some(principal));
6360        }
6361        publish
6362    }
6363
6364    /// Disable `require_auth` on this database, reverting it to credentialless
6365    /// mode. This is the **recovery** path — it requires the handle to already
6366    /// be open (and therefore already authenticated if `require_auth` was on).
6367    ///
6368    /// After this call, the database can be reopened with plain
6369    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
6370    /// credentials. All existing users and roles are preserved in the catalog
6371    /// (so `require_auth` can be re-enabled without recreating them), but they
6372    /// are no longer consulted for enforcement.
6373    ///
6374    /// For true **offline** recovery (when credentials are lost and no
6375    /// authenticated handle is available), the caller opens the database
6376    /// directly via the catalog file (filesystem access required) and calls
6377    /// this method — see the CLI's `auth disable-offline` command.
6378    ///
6379    /// See `docs/15-credential-enforcement.md` §4.7.
6380    pub fn disable_auth(&self) -> Result<()> {
6381        let _ddl = self.ddl_lock.lock();
6382        let _security_write = self.security_write()?;
6383        let _commit = self.commit_lock.lock();
6384        let epoch = self.epoch.bump_assigned();
6385        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6386        let mut next_catalog = self.catalog.read().clone();
6387        if !next_catalog.require_auth {
6388            return Err(MongrelError::InvalidArgument(
6389                "database does not have require_auth enabled".into(),
6390            ));
6391        }
6392        next_catalog.require_auth = false;
6393        advance_security_version(&mut next_catalog)?;
6394        next_catalog.db_epoch = epoch.0;
6395        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6396        // Clear the cached principal — enforcement is now off.
6397        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6398            *self.principal.write() = None;
6399        }
6400        publish
6401    }
6402
6403    /// Enforcement check: if the catalog has `require_auth = true`, verify
6404    /// that the cached principal satisfies `perm`. Called by every
6405    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
6406    /// Table/Transaction/MongrelSession operations).
6407    ///
6408    /// On a credentialless database this is a no-op (`Ok(())`).
6409    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
6410        self.ensure_owner_process()?;
6411        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
6412            return Err(MongrelError::ReadOnlyReplica);
6413        }
6414        if self.principal.read().is_some() {
6415            self.refresh_principal().map_err(|error| match error {
6416                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
6417                error => error,
6418            })?;
6419        }
6420        if !self.catalog.read().require_auth {
6421            return Ok(());
6422        }
6423        let guard = self.principal.read();
6424        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
6425        if p.has_permission(perm) {
6426            Ok(())
6427        } else {
6428            Err(MongrelError::PermissionDenied {
6429                required: perm.clone(),
6430                principal: p.username.clone(),
6431            })
6432        }
6433    }
6434
6435    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
6436    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
6437    /// other callers that know the operation kind + table name but don't want
6438    /// to construct the full `Permission` enum value themselves.
6439    pub fn require_table(
6440        &self,
6441        table: &str,
6442        perm: crate::auth_state::RequiredPermission,
6443    ) -> Result<()> {
6444        self.require(&perm.into_permission(table))
6445    }
6446
6447    pub fn triggers(&self) -> Vec<StoredTrigger> {
6448        self.catalog
6449            .read()
6450            .triggers
6451            .iter()
6452            .map(|t| t.trigger.clone())
6453            .collect()
6454    }
6455
6456    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
6457        self.catalog
6458            .read()
6459            .triggers
6460            .iter()
6461            .find(|t| t.trigger.name == name)
6462            .map(|t| t.trigger.clone())
6463    }
6464
6465    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6466        self.create_trigger_inner(trigger, None, None)
6467    }
6468
6469    pub fn create_trigger_controlled<F>(
6470        &self,
6471        trigger: StoredTrigger,
6472        mut before_publish: F,
6473    ) -> Result<StoredTrigger>
6474    where
6475        F: FnMut() -> Result<()>,
6476    {
6477        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
6478    }
6479
6480    pub fn create_trigger_as_controlled<F>(
6481        &self,
6482        trigger: StoredTrigger,
6483        principal: Option<&crate::auth::Principal>,
6484        mut before_publish: F,
6485    ) -> Result<StoredTrigger>
6486    where
6487        F: FnMut() -> Result<()>,
6488    {
6489        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
6490    }
6491
6492    fn create_trigger_inner(
6493        &self,
6494        mut trigger: StoredTrigger,
6495        principal: Option<&crate::auth::Principal>,
6496        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6497    ) -> Result<StoredTrigger> {
6498        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6499        let _g = self.ddl_lock.lock();
6500        let _security_write = self.security_write()?;
6501        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6502        trigger.validate()?;
6503        self.validate_trigger_references(&trigger)
6504            .map_err(trigger_validation_error)?;
6505        {
6506            let cat = self.catalog.read();
6507            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
6508                return Err(MongrelError::TriggerValidation(format!(
6509                    "trigger {:?} already exists",
6510                    trigger.name
6511                )));
6512            }
6513        }
6514        let commit_lock = Arc::clone(&self.commit_lock);
6515        let _c = commit_lock.lock();
6516        let epoch = self.epoch.bump_assigned();
6517        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6518        trigger.created_epoch = epoch.0;
6519        trigger.updated_epoch = epoch.0;
6520        let mut next_catalog = self.catalog.read().clone();
6521        next_catalog
6522            .triggers
6523            .push(TriggerEntry::from(trigger.clone()));
6524        next_catalog.db_epoch = epoch.0;
6525        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6526        Ok(trigger)
6527    }
6528
6529    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6530        self.create_or_replace_trigger_inner(trigger, None, None)
6531    }
6532
6533    pub fn create_or_replace_trigger_controlled<F>(
6534        &self,
6535        trigger: StoredTrigger,
6536        mut before_publish: F,
6537    ) -> Result<StoredTrigger>
6538    where
6539        F: FnMut() -> Result<()>,
6540    {
6541        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
6542    }
6543
6544    pub fn create_or_replace_trigger_as_controlled<F>(
6545        &self,
6546        trigger: StoredTrigger,
6547        principal: Option<&crate::auth::Principal>,
6548        mut before_publish: F,
6549    ) -> Result<StoredTrigger>
6550    where
6551        F: FnMut() -> Result<()>,
6552    {
6553        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
6554    }
6555
6556    fn create_or_replace_trigger_inner(
6557        &self,
6558        trigger: StoredTrigger,
6559        principal: Option<&crate::auth::Principal>,
6560        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6561    ) -> Result<StoredTrigger> {
6562        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6563        let _g = self.ddl_lock.lock();
6564        let _security_write = self.security_write()?;
6565        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6566        trigger.validate()?;
6567        self.validate_trigger_references(&trigger)
6568            .map_err(trigger_validation_error)?;
6569        let commit_lock = Arc::clone(&self.commit_lock);
6570        let _c = commit_lock.lock();
6571        let epoch = self.epoch.bump_assigned();
6572        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6573        let mut next_catalog = self.catalog.read().clone();
6574        let replaced = {
6575            let next = match next_catalog
6576                .triggers
6577                .iter()
6578                .position(|t| t.trigger.name == trigger.name)
6579            {
6580                Some(idx) => {
6581                    let next = next_catalog.triggers[idx]
6582                        .trigger
6583                        .replaced(trigger.clone(), epoch.0)?;
6584                    next_catalog.triggers[idx] = TriggerEntry::from(next.clone());
6585                    next
6586                }
6587                None => {
6588                    let mut next = trigger;
6589                    next.created_epoch = epoch.0;
6590                    next.updated_epoch = epoch.0;
6591                    next_catalog.triggers.push(TriggerEntry::from(next.clone()));
6592                    next
6593                }
6594            };
6595            next_catalog.db_epoch = epoch.0;
6596            next
6597        };
6598        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6599        Ok(replaced)
6600    }
6601
6602    pub fn drop_trigger(&self, name: &str) -> Result<()> {
6603        self.drop_trigger_with_epoch(name).map(|_| ())
6604    }
6605
6606    /// Drop one trigger and return the exact catalog publication epoch.
6607    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
6608        self.drop_triggers_with_epoch(&[name.to_string()])
6609    }
6610
6611    pub fn drop_trigger_with_epoch_controlled<F>(
6612        &self,
6613        name: &str,
6614        before_publish: F,
6615    ) -> Result<Epoch>
6616    where
6617        F: FnMut() -> Result<()>,
6618    {
6619        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
6620    }
6621
6622    /// Atomically drop several triggers in one catalog publication.
6623    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
6624        self.drop_triggers_with_epoch_inner(names, None, None)
6625    }
6626
6627    pub fn drop_triggers_with_epoch_controlled<F>(
6628        &self,
6629        names: &[String],
6630        mut before_publish: F,
6631    ) -> Result<Epoch>
6632    where
6633        F: FnMut() -> Result<()>,
6634    {
6635        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
6636    }
6637
6638    pub fn drop_triggers_with_epoch_as_controlled<F>(
6639        &self,
6640        names: &[String],
6641        principal: Option<&crate::auth::Principal>,
6642        mut before_publish: F,
6643    ) -> Result<Epoch>
6644    where
6645        F: FnMut() -> Result<()>,
6646    {
6647        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
6648    }
6649
6650    fn drop_triggers_with_epoch_inner(
6651        &self,
6652        names: &[String],
6653        principal: Option<&crate::auth::Principal>,
6654        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6655    ) -> Result<Epoch> {
6656        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6657        if names.is_empty() {
6658            return Err(MongrelError::InvalidArgument(
6659                "at least one trigger name is required".into(),
6660            ));
6661        }
6662        let _g = self.ddl_lock.lock();
6663        let _security_write = self.security_write()?;
6664        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6665        {
6666            let cat = self.catalog.read();
6667            for name in names {
6668                if !cat.triggers.iter().any(|t| t.trigger.name == *name) {
6669                    return Err(MongrelError::NotFound(format!(
6670                        "trigger {name:?} not found"
6671                    )));
6672                }
6673            }
6674        }
6675        let commit_lock = Arc::clone(&self.commit_lock);
6676        let _c = commit_lock.lock();
6677        let epoch = self.epoch.bump_assigned();
6678        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6679        let mut next_catalog = self.catalog.read().clone();
6680        next_catalog
6681            .triggers
6682            .retain(|trigger| !names.contains(&trigger.trigger.name));
6683        next_catalog.db_epoch = epoch.0;
6684        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6685        Ok(epoch)
6686    }
6687
6688    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
6689        self.catalog.read().external_tables.clone()
6690    }
6691
6692    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
6693        self.catalog
6694            .read()
6695            .external_tables
6696            .iter()
6697            .find(|t| t.name == name)
6698            .cloned()
6699    }
6700
6701    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
6702        self.create_external_table_inner(entry, None)
6703    }
6704
6705    pub fn create_external_table_controlled<F>(
6706        &self,
6707        entry: ExternalTableEntry,
6708        mut before_publish: F,
6709    ) -> Result<ExternalTableEntry>
6710    where
6711        F: FnMut() -> Result<()>,
6712    {
6713        self.create_external_table_inner(entry, Some(&mut before_publish))
6714    }
6715
6716    fn create_external_table_inner(
6717        &self,
6718        mut entry: ExternalTableEntry,
6719        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6720    ) -> Result<ExternalTableEntry> {
6721        self.require(&crate::auth::Permission::Ddl)?;
6722        let _g = self.ddl_lock.lock();
6723        let _security_write = self.security_write()?;
6724        self.require(&crate::auth::Permission::Ddl)?;
6725        entry.validate()?;
6726        {
6727            let cat = self.catalog.read();
6728            if cat.live(&entry.name).is_some()
6729                || cat.external_tables.iter().any(|t| t.name == entry.name)
6730            {
6731                return Err(MongrelError::InvalidArgument(format!(
6732                    "table {:?} already exists",
6733                    entry.name
6734                )));
6735            }
6736        }
6737        let commit_lock = Arc::clone(&self.commit_lock);
6738        let _c = commit_lock.lock();
6739        // A prior durable drop may have left connector state behind if its
6740        // cleanup failed or the process crashed. Never let a new table with
6741        // the same name inherit that stale state.
6742        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
6743        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
6744        let epoch = self.epoch.bump_assigned();
6745        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6746        entry.created_epoch = epoch.0;
6747        let mut next_catalog = self.catalog.read().clone();
6748        next_catalog.external_tables.push(entry.clone());
6749        next_catalog.db_epoch = epoch.0;
6750        self.publish_catalog_candidate_with_prelude(
6751            next_catalog,
6752            epoch,
6753            &mut _epoch_guard,
6754            before_publish,
6755            vec![(
6756                EXTERNAL_TABLE_ID,
6757                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6758                    name: entry.name.clone(),
6759                    generation_epoch: epoch.0,
6760                }),
6761            )],
6762        )?;
6763        Ok(entry)
6764    }
6765
6766    pub fn drop_external_table(&self, name: &str) -> Result<()> {
6767        self.drop_external_table_with_epoch(name).map(|_| ())
6768    }
6769
6770    /// Drop an external table and return the exact catalog publication epoch.
6771    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
6772        self.drop_external_table_with_epoch_inner(name, None)
6773    }
6774
6775    pub fn drop_external_table_with_epoch_controlled<F>(
6776        &self,
6777        name: &str,
6778        mut before_publish: F,
6779    ) -> Result<Epoch>
6780    where
6781        F: FnMut() -> Result<()>,
6782    {
6783        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
6784    }
6785
6786    fn drop_external_table_with_epoch_inner(
6787        &self,
6788        name: &str,
6789        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6790    ) -> Result<Epoch> {
6791        self.require(&crate::auth::Permission::Ddl)?;
6792        let _g = self.ddl_lock.lock();
6793        let _security_write = self.security_write()?;
6794        self.require(&crate::auth::Permission::Ddl)?;
6795        let commit_lock = Arc::clone(&self.commit_lock);
6796        let _c = commit_lock.lock();
6797        let epoch = self.epoch.bump_assigned();
6798        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6799        let mut next_catalog = self.catalog.read().clone();
6800        let before = next_catalog.external_tables.len();
6801        next_catalog.external_tables.retain(|t| t.name != name);
6802        if next_catalog.external_tables.len() == before {
6803            return Err(MongrelError::NotFound(format!(
6804                "external table {name:?} not found"
6805            )));
6806        }
6807        next_catalog.db_epoch = epoch.0;
6808        self.publish_catalog_candidate_with_prelude(
6809            next_catalog,
6810            epoch,
6811            &mut _epoch_guard,
6812            before_publish,
6813            vec![(
6814                EXTERNAL_TABLE_ID,
6815                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6816                    name: name.to_string(),
6817                    generation_epoch: epoch.0,
6818                }),
6819            )],
6820        )?;
6821        let state_dir = self.root.join(VTAB_DIR).join(name);
6822        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
6823            return Err(MongrelError::DurableCommit {
6824                epoch: epoch.0,
6825                message: format!(
6826                    "external table was dropped but connector-state cleanup failed: {error}"
6827                ),
6828            });
6829        }
6830        Ok(epoch)
6831    }
6832
6833    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
6834        let txn_id = self.alloc_txn_id()?;
6835        let (principal, catalog_bound) = self.transaction_principal_snapshot();
6836        self.commit_transaction_with_external_states(
6837            txn_id,
6838            self.epoch.visible(),
6839            Vec::new(),
6840            vec![(name.to_string(), state.to_vec())],
6841            Vec::new(),
6842            principal,
6843            catalog_bound,
6844            None,
6845        )
6846        .map(|(epoch, _)| epoch)
6847    }
6848
6849    pub fn trigger_config(&self) -> TriggerConfig {
6850        use std::sync::atomic::Ordering;
6851        TriggerConfig {
6852            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
6853            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
6854            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
6855        }
6856    }
6857
6858    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
6859        use std::sync::atomic::Ordering;
6860        if config.max_depth == 0 {
6861            return Err(MongrelError::InvalidArgument(
6862                "trigger max_depth must be greater than 0".into(),
6863            ));
6864        }
6865        self.trigger_recursive
6866            .store(config.recursive_triggers, Ordering::Relaxed);
6867        self.trigger_max_depth
6868            .store(config.max_depth, Ordering::Relaxed);
6869        self.trigger_max_loop_iterations
6870            .store(config.max_loop_iterations, Ordering::Relaxed);
6871        Ok(())
6872    }
6873
6874    pub fn set_recursive_triggers(&self, recursive: bool) {
6875        use std::sync::atomic::Ordering;
6876        self.trigger_recursive.store(recursive, Ordering::Relaxed);
6877    }
6878
6879    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
6880    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
6881    /// as a low-latency wake-up.
6882    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
6883        self.notify.subscribe()
6884    }
6885
6886    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
6887        self.change_wake.subscribe()
6888    }
6889
6890    /// Reconstruct committed row changes from the retained shared WAL. Event
6891    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
6892    /// resumes before the oldest retained commit receives `gap = true` and
6893    /// must rebootstrap instead of silently skipping changes.
6894    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
6895        let control = crate::ExecutionControl::new(None);
6896        self.change_events_since_controlled(last_event_id, &control)
6897    }
6898
6899    /// Reconstruct committed changes with cooperative cancellation and bounds.
6900    pub fn change_events_since_controlled(
6901        &self,
6902        last_event_id: Option<&str>,
6903        control: &crate::ExecutionControl,
6904    ) -> Result<CdcBatch> {
6905        use crate::wal::Op;
6906
6907        control.checkpoint()?;
6908        let resume = match last_event_id {
6909            Some(id) => {
6910                let (epoch, index) = id.split_once(':').ok_or_else(|| {
6911                    MongrelError::InvalidArgument(format!(
6912                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
6913                    ))
6914                })?;
6915                Some((
6916                    epoch.parse::<u64>().map_err(|error| {
6917                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
6918                    })?,
6919                    index.parse::<u32>().map_err(|error| {
6920                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
6921                    })?,
6922                ))
6923            }
6924            None => None,
6925        };
6926
6927        let mut wal = self.shared_wal.lock();
6928        wal.group_sync()?;
6929        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6930        let records = crate::wal::SharedWal::replay_with_dek_controlled(
6931            &self.root,
6932            wal_dek.as_ref(),
6933            control,
6934            CDC_MAX_WAL_RECORDS,
6935            CDC_MAX_WAL_REPLAY_BYTES,
6936        )?;
6937        drop(wal);
6938        control.checkpoint()?;
6939
6940        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
6941        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
6942        for (index, record) in records.iter().enumerate() {
6943            if index % 256 == 0 {
6944                control.checkpoint()?;
6945            }
6946            if let Op::TxnCommit { epoch, added_runs } = &record.op {
6947                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
6948            }
6949            if let Op::SpilledRows { table_id, rows } = &record.op {
6950                spilled_payloads
6951                    .entry((record.txn_id, *table_id))
6952                    .or_default()
6953                    .push(rows);
6954            }
6955        }
6956        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
6957        let current_epoch = self.epoch.committed().0;
6958        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
6959        let gap = resume.is_some_and(|(epoch, _)| {
6960            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
6961        });
6962        if gap {
6963            return Ok(CdcBatch {
6964                events: Vec::new(),
6965                current_epoch,
6966                earliest_epoch,
6967                gap: true,
6968            });
6969        }
6970
6971        let table_names: HashMap<u64, String> = self
6972            .catalog
6973            .read()
6974            .tables
6975            .iter()
6976            .map(|entry| (entry.table_id, entry.name.clone()))
6977            .collect();
6978        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
6979        let mut retained_bytes = 0_usize;
6980        for (index, record) in records.iter().enumerate() {
6981            if index % 256 == 0 {
6982                control.checkpoint()?;
6983            }
6984            if !commits.contains_key(&record.txn_id) {
6985                continue;
6986            }
6987            let Op::BeforeImage {
6988                table_id,
6989                row_id,
6990                row,
6991            } = &record.op
6992            else {
6993                continue;
6994            };
6995            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6996                return Err(MongrelError::ResourceLimitExceeded {
6997                    resource: "CDC before-image bytes",
6998                    requested: row.len(),
6999                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
7000                });
7001            }
7002            let before: crate::memtable::Row = bincode::deserialize(row)?;
7003            if before_images.len() >= CDC_MAX_ROWS {
7004                return Err(MongrelError::ResourceLimitExceeded {
7005                    resource: "CDC before-image rows",
7006                    requested: before_images.len().saturating_add(1),
7007                    limit: CDC_MAX_ROWS,
7008                });
7009            }
7010            charge_cdc_bytes(
7011                &mut retained_bytes,
7012                cdc_row_storage_bytes(&before),
7013                "CDC retained bytes",
7014            )?;
7015            before_images.insert((record.txn_id, *table_id, row_id.0), before);
7016        }
7017        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
7018        let mut events = Vec::new();
7019        let mut decoded_rows = before_images.len();
7020        for (record_index, record) in records.iter().enumerate() {
7021            if record_index % 256 == 0 {
7022                control.checkpoint()?;
7023            }
7024            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
7025                continue;
7026            };
7027            let event = match &record.op {
7028                Op::Put { table_id, rows } => {
7029                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
7030                        return Err(MongrelError::ResourceLimitExceeded {
7031                            resource: "CDC inline row bytes",
7032                            requested: rows.len(),
7033                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
7034                        });
7035                    }
7036                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
7037                    decoded_rows = decoded_rows.saturating_add(rows.len());
7038                    if decoded_rows > CDC_MAX_ROWS {
7039                        return Err(MongrelError::ResourceLimitExceeded {
7040                            resource: "CDC decoded rows",
7041                            requested: decoded_rows,
7042                            limit: CDC_MAX_ROWS,
7043                        });
7044                    }
7045                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
7046                    let mut peak_bytes = retained_bytes;
7047                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
7048                    let data = serde_json::to_value(rows)
7049                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
7050                    Some((*table_id, "put", data, event_bytes))
7051                }
7052                Op::Delete { table_id, row_ids } => {
7053                    let before = row_ids
7054                        .iter()
7055                        .filter_map(|row_id| {
7056                            before_images
7057                                .get(&(record.txn_id, *table_id, row_id.0))
7058                                .cloned()
7059                        })
7060                        .collect::<Vec<_>>();
7061                    let event_bytes = cdc_rows_json_bytes(&before)
7062                        .saturating_add(
7063                            row_ids
7064                                .len()
7065                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
7066                        )
7067                        .saturating_add(512);
7068                    let mut peak_bytes = retained_bytes;
7069                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
7070                    Some((
7071                        *table_id,
7072                        "delete",
7073                        serde_json::json!({
7074                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
7075                            "before": before,
7076                        }),
7077                        event_bytes,
7078                    ))
7079                }
7080                Op::TruncateTable { table_id } => {
7081                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
7082                }
7083                _ => None,
7084            };
7085            if let Some((table_id, op, data, event_bytes)) = event {
7086                let index = operation_indices.entry(record.txn_id).or_insert(0);
7087                let event_position = (*commit_epoch, *index);
7088                *index = index.saturating_add(1);
7089                if resume.is_some_and(|position| event_position <= position) {
7090                    continue;
7091                }
7092                if events.len() >= CDC_MAX_EVENTS {
7093                    return Err(MongrelError::ResourceLimitExceeded {
7094                        resource: "CDC events",
7095                        requested: events.len().saturating_add(1),
7096                        limit: CDC_MAX_EVENTS,
7097                    });
7098                }
7099                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
7100                events.push(ChangeEvent {
7101                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
7102                    channel: "changes".into(),
7103                    table_id: Some(table_id),
7104                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
7105                    op: op.into(),
7106                    epoch: *commit_epoch,
7107                    txn_id: Some(record.txn_id),
7108                    message: None,
7109                    data: Some(data),
7110                });
7111            }
7112            if let Op::TxnCommit { added_runs, .. } = &record.op {
7113                for run in added_runs {
7114                    control.checkpoint()?;
7115                    let index = operation_indices.entry(record.txn_id).or_insert(0);
7116                    let event_position = (*commit_epoch, *index);
7117                    *index = index.saturating_add(1);
7118                    if resume.is_some_and(|position| event_position <= position) {
7119                        continue;
7120                    }
7121                    let mut rows = if let Some(payloads) =
7122                        spilled_payloads.get(&(record.txn_id, run.table_id))
7123                    {
7124                        let mut rows = Vec::new();
7125                        for payload in payloads {
7126                            control.checkpoint()?;
7127                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
7128                                return Err(MongrelError::ResourceLimitExceeded {
7129                                    resource: "CDC spilled row bytes",
7130                                    requested: payload.len(),
7131                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
7132                                });
7133                            }
7134                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
7135                            if decoded_rows
7136                                .saturating_add(rows.len())
7137                                .saturating_add(chunk.len())
7138                                > CDC_MAX_ROWS
7139                            {
7140                                return Err(MongrelError::ResourceLimitExceeded {
7141                                    resource: "CDC decoded rows",
7142                                    requested: decoded_rows
7143                                        .saturating_add(rows.len())
7144                                        .saturating_add(chunk.len()),
7145                                    limit: CDC_MAX_ROWS,
7146                                });
7147                            }
7148                            rows.extend(chunk);
7149                        }
7150                        rows
7151                    } else {
7152                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
7153                            return Ok(CdcBatch {
7154                                events: Vec::new(),
7155                                current_epoch,
7156                                earliest_epoch,
7157                                gap: true,
7158                            });
7159                        };
7160                        let table = handle.lock();
7161                        let mut reader = match table.open_reader(run.run_id) {
7162                            Ok(reader) => reader,
7163                            Err(_) => {
7164                                return Ok(CdcBatch {
7165                                    events: Vec::new(),
7166                                    current_epoch,
7167                                    earliest_epoch,
7168                                    gap: true,
7169                                })
7170                            }
7171                        };
7172                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
7173                        let rows = reader.all_rows_controlled(control, remaining)?;
7174                        drop(reader);
7175                        drop(table);
7176                        rows
7177                    };
7178                    for row in &mut rows {
7179                        row.committed_epoch = Epoch(*commit_epoch);
7180                    }
7181                    decoded_rows = decoded_rows.saturating_add(rows.len());
7182                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
7183                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
7184                    if events.len() >= CDC_MAX_EVENTS {
7185                        return Err(MongrelError::ResourceLimitExceeded {
7186                            resource: "CDC events",
7187                            requested: events.len().saturating_add(1),
7188                            limit: CDC_MAX_EVENTS,
7189                        });
7190                    }
7191                    events.push(ChangeEvent {
7192                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
7193                        channel: "changes".into(),
7194                        table_id: Some(run.table_id),
7195                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
7196                        op: "put_run".into(),
7197                        epoch: *commit_epoch,
7198                        txn_id: Some(record.txn_id),
7199                        message: None,
7200                        data: Some(serde_json::json!({
7201                            "run_id": run.run_id.to_string(),
7202                            "row_count": run.row_count,
7203                            "min_row_id": run.min_row_id,
7204                            "max_row_id": run.max_row_id,
7205                            "rows": rows,
7206                        })),
7207                    });
7208                }
7209            }
7210        }
7211        control.checkpoint()?;
7212        Ok(CdcBatch {
7213            events,
7214            current_epoch,
7215            earliest_epoch,
7216            gap: false,
7217        })
7218    }
7219
7220    /// Publish a notification message on a named channel. Reaches all active
7221    /// subscribers (daemon `/events`, application listeners).
7222    pub fn notify(&self, channel: &str, message: Option<String>) {
7223        let _ = self.notify.send(ChangeEvent {
7224            id: None,
7225            channel: channel.to_string(),
7226            table_id: None,
7227            table: String::new(),
7228            op: "notify".into(),
7229            epoch: self.epoch.visible().0,
7230            txn_id: None,
7231            message,
7232            data: None,
7233        });
7234    }
7235
7236    pub fn call_procedure(
7237        &self,
7238        name: &str,
7239        args: HashMap<String, crate::Value>,
7240    ) -> Result<ProcedureCallResult> {
7241        self.call_procedure_as(name, args, None)
7242    }
7243
7244    pub fn call_procedure_as(
7245        &self,
7246        name: &str,
7247        args: HashMap<String, crate::Value>,
7248        principal: Option<&crate::auth::Principal>,
7249    ) -> Result<ProcedureCallResult> {
7250        let control = crate::ExecutionControl::new(None);
7251        self.call_procedure_as_controlled(name, args, principal, &control, || true)
7252    }
7253
7254    /// Execute only the exact procedure revision previously authorized by the
7255    /// caller. A dropped or replaced definition fails closed.
7256    #[doc(hidden)]
7257    pub fn call_procedure_as_bound(
7258        &self,
7259        expected: &StoredProcedure,
7260        args: HashMap<String, crate::Value>,
7261        principal: Option<&crate::auth::Principal>,
7262    ) -> Result<ProcedureCallResult> {
7263        self.require_for(principal, &crate::auth::Permission::All)?;
7264        let procedure = self.procedure(&expected.name).ok_or_else(|| {
7265            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
7266        })?;
7267        if &procedure != expected {
7268            return Err(MongrelError::Conflict(format!(
7269                "procedure {:?} changed after request authorization",
7270                expected.name
7271            )));
7272        }
7273        let control = crate::ExecutionControl::new(None);
7274        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
7275    }
7276
7277    /// Execute a procedure with cooperative cancellation during preparation.
7278    /// `before_commit` runs after every procedure step has succeeded and
7279    /// immediately before a write procedure commits. Returning `false` aborts
7280    /// the transaction without publishing it.
7281    #[doc(hidden)]
7282    pub fn call_procedure_as_controlled<F>(
7283        &self,
7284        name: &str,
7285        args: HashMap<String, crate::Value>,
7286        principal: Option<&crate::auth::Principal>,
7287        control: &crate::ExecutionControl,
7288        before_commit: F,
7289    ) -> Result<ProcedureCallResult>
7290    where
7291        F: FnOnce() -> bool,
7292    {
7293        // v1 requires ALL to call procedures on a require_auth database; a
7294        // finer SECURITY DEFINER-style marker is a future extension (spec §9
7295        // decision 1).
7296        self.require_for(principal, &crate::auth::Permission::All)?;
7297        let procedure = self
7298            .procedure(name)
7299            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
7300        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
7301    }
7302
7303    fn execute_procedure_as_controlled<F>(
7304        &self,
7305        procedure: StoredProcedure,
7306        args: HashMap<String, crate::Value>,
7307        principal: Option<&crate::auth::Principal>,
7308        control: &crate::ExecutionControl,
7309        before_commit: F,
7310    ) -> Result<ProcedureCallResult>
7311    where
7312        F: FnOnce() -> bool,
7313    {
7314        let args = bind_procedure_args(&procedure, args)?;
7315        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
7316        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
7317        if has_writes {
7318            let mut tx = self.begin_as(principal.cloned());
7319            let run = (|| {
7320                for (step_index, step) in procedure.body.steps.iter().enumerate() {
7321                    if step_index % 256 == 0 {
7322                        control.checkpoint()?;
7323                    }
7324                    let output = self.execute_procedure_step(
7325                        step,
7326                        &args,
7327                        &outputs,
7328                        Some(&mut tx),
7329                        principal,
7330                        Some(control),
7331                    )?;
7332                    outputs.insert(step.id().to_string(), output);
7333                }
7334                control.checkpoint()?;
7335                eval_return_output(&procedure.body.return_value, &args, &outputs)
7336            })();
7337            match run {
7338                Ok(output) => {
7339                    control.checkpoint()?;
7340                    if !before_commit() {
7341                        tx.rollback();
7342                        return Err(MongrelError::Cancelled);
7343                    }
7344                    let epoch = tx.commit()?.0;
7345                    Ok(ProcedureCallResult {
7346                        epoch: Some(epoch),
7347                        output,
7348                    })
7349                }
7350                Err(e) => {
7351                    tx.rollback();
7352                    Err(e)
7353                }
7354            }
7355        } else {
7356            for (step_index, step) in procedure.body.steps.iter().enumerate() {
7357                if step_index % 256 == 0 {
7358                    control.checkpoint()?;
7359                }
7360                let output = self.execute_procedure_step(
7361                    step,
7362                    &args,
7363                    &outputs,
7364                    None,
7365                    principal,
7366                    Some(control),
7367                )?;
7368                outputs.insert(step.id().to_string(), output);
7369            }
7370            control.checkpoint()?;
7371            Ok(ProcedureCallResult {
7372                epoch: None,
7373                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
7374            })
7375        }
7376    }
7377
7378    fn execute_procedure_step(
7379        &self,
7380        step: &ProcedureStep,
7381        args: &HashMap<String, crate::Value>,
7382        outputs: &HashMap<String, ProcedureCallOutput>,
7383        tx: Option<&mut crate::txn::Transaction<'_>>,
7384        principal: Option<&crate::auth::Principal>,
7385        control: Option<&crate::ExecutionControl>,
7386    ) -> Result<ProcedureCallOutput> {
7387        if let Some(control) = control {
7388            control.checkpoint()?;
7389        }
7390        match step {
7391            ProcedureStep::NativeQuery {
7392                table,
7393                conditions,
7394                projection,
7395                limit,
7396                ..
7397            } => {
7398                let mut q = crate::Query::new();
7399                for condition in conditions {
7400                    q = q.and(eval_condition(condition, args, outputs)?);
7401                }
7402                let fallback_control = crate::ExecutionControl::new(None);
7403                let query_control = control.unwrap_or(&fallback_control);
7404                let mut rows = self.query_for_principal_controlled(
7405                    table,
7406                    &q,
7407                    projection.as_deref(),
7408                    principal,
7409                    false,
7410                    query_control,
7411                )?;
7412                if let Some(limit) = limit {
7413                    rows.truncate(*limit);
7414                }
7415                let mut output = Vec::with_capacity(rows.len());
7416                for (row_index, row) in rows.into_iter().enumerate() {
7417                    if row_index % 256 == 0 {
7418                        if let Some(control) = control {
7419                            control.checkpoint()?;
7420                        }
7421                    }
7422                    output.push(ProcedureCallRow {
7423                        row_id: Some(row.row_id),
7424                        columns: row.columns,
7425                    });
7426                }
7427                Ok(ProcedureCallOutput::Rows(output))
7428            }
7429            ProcedureStep::Put {
7430                table,
7431                cells,
7432                returning,
7433                ..
7434            } => {
7435                let tx = tx.ok_or_else(|| {
7436                    MongrelError::InvalidArgument(
7437                        "write procedure step requires a transaction".into(),
7438                    )
7439                })?;
7440                let cells = eval_cells(cells, args, outputs)?;
7441                if *returning {
7442                    let out = tx.put_returning(table, cells)?;
7443                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7444                        row_id: None,
7445                        columns: out.row.columns.into_iter().collect(),
7446                    }))
7447                } else {
7448                    tx.put(table, cells)?;
7449                    Ok(ProcedureCallOutput::Null)
7450                }
7451            }
7452            ProcedureStep::Upsert {
7453                table,
7454                cells,
7455                update_cells,
7456                returning,
7457                ..
7458            } => {
7459                let tx = tx.ok_or_else(|| {
7460                    MongrelError::InvalidArgument(
7461                        "write procedure step requires a transaction".into(),
7462                    )
7463                })?;
7464                let cells = eval_cells(cells, args, outputs)?;
7465                let action = match update_cells {
7466                    Some(update_cells) => {
7467                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
7468                    }
7469                    None => crate::UpsertAction::DoNothing,
7470                };
7471                let out = tx.upsert(table, cells, action)?;
7472                if *returning {
7473                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7474                        row_id: None,
7475                        columns: out.row.columns.into_iter().collect(),
7476                    }))
7477                } else {
7478                    Ok(ProcedureCallOutput::Null)
7479                }
7480            }
7481            ProcedureStep::DeleteByPk { table, pk, .. } => {
7482                let tx = tx.ok_or_else(|| {
7483                    MongrelError::InvalidArgument(
7484                        "write procedure step requires a transaction".into(),
7485                    )
7486                })?;
7487                let pk = eval_value(pk, args, outputs)?;
7488                let handle = self.table(table)?;
7489                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
7490                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
7491                })?;
7492                tx.delete(table, row_id)?;
7493                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
7494            }
7495            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
7496                "DeleteRows procedure step is not supported by the core executor yet".into(),
7497            )),
7498            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
7499                "SqlQuery procedure step must be executed by mongreldb-query".into(),
7500            )),
7501        }
7502    }
7503
7504    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
7505        let cat = self.catalog.read();
7506        for step in &procedure.body.steps {
7507            let Some(table_name) = step.table() else {
7508                continue;
7509            };
7510            let schema = &cat
7511                .live(table_name)
7512                .ok_or_else(|| {
7513                    MongrelError::InvalidArgument(format!(
7514                        "procedure {:?} references unknown table {table_name:?}",
7515                        procedure.name
7516                    ))
7517                })?
7518                .schema;
7519            match step {
7520                ProcedureStep::NativeQuery {
7521                    conditions,
7522                    projection,
7523                    ..
7524                } => {
7525                    for condition in conditions {
7526                        validate_condition_columns(condition, schema)?;
7527                    }
7528                    if let Some(projection) = projection {
7529                        for id in projection {
7530                            validate_column_id(*id, schema)?;
7531                        }
7532                    }
7533                }
7534                ProcedureStep::Put { cells, .. } => {
7535                    for cell in cells {
7536                        validate_column_id(cell.column_id, schema)?;
7537                    }
7538                }
7539                ProcedureStep::Upsert {
7540                    cells,
7541                    update_cells,
7542                    ..
7543                } => {
7544                    for cell in cells {
7545                        validate_column_id(cell.column_id, schema)?;
7546                    }
7547                    if let Some(update_cells) = update_cells {
7548                        for cell in update_cells {
7549                            validate_column_id(cell.column_id, schema)?;
7550                        }
7551                    }
7552                }
7553                ProcedureStep::DeleteByPk { .. } => {
7554                    if schema.primary_key().is_none() {
7555                        return Err(MongrelError::InvalidArgument(format!(
7556                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
7557                            procedure.name
7558                        )));
7559                    }
7560                }
7561                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
7562            }
7563        }
7564        Ok(())
7565    }
7566
7567    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
7568        let cat = self.catalog.read();
7569        let target_schema = match &trigger.target {
7570            TriggerTarget::Table(target_name) => cat
7571                .live(target_name)
7572                .ok_or_else(|| {
7573                    MongrelError::InvalidArgument(format!(
7574                        "trigger {:?} references unknown target table {target_name:?}",
7575                        trigger.name
7576                    ))
7577                })?
7578                .schema
7579                .clone(),
7580            TriggerTarget::View(_) => Schema {
7581                columns: trigger.target_columns.clone(),
7582                ..Schema::default()
7583            },
7584        };
7585        for col in &trigger.update_of {
7586            if target_schema.column(col).is_none() {
7587                return Err(MongrelError::InvalidArgument(format!(
7588                    "trigger {:?} UPDATE OF references unknown column {col:?}",
7589                    trigger.name
7590                )));
7591            }
7592        }
7593        if let Some(expr) = &trigger.when {
7594            validate_trigger_expr(expr, &target_schema, trigger.event)?;
7595        }
7596        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
7597        for step in &trigger.program.steps {
7598            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
7599            {
7600                return Err(MongrelError::InvalidArgument(
7601                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
7602                ));
7603            }
7604            validate_trigger_step(
7605                step,
7606                &cat,
7607                &target_schema,
7608                trigger.event,
7609                &mut select_schemas,
7610            )?;
7611        }
7612        Ok(())
7613    }
7614
7615    /// Begin a new transaction reading at the current visible epoch.
7616    pub fn begin(&self) -> crate::txn::Transaction<'_> {
7617        self.begin_with_isolation(crate::txn::IsolationLevel::default())
7618    }
7619
7620    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
7621        let principal = self.principal.read().clone();
7622        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7623            let catalog = self.catalog.read();
7624            catalog.require_auth || principal.user_id != 0
7625        });
7626        (principal, catalog_bound)
7627    }
7628
7629    pub fn begin_as(
7630        &self,
7631        principal: Option<crate::auth::Principal>,
7632    ) -> crate::txn::Transaction<'_> {
7633        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7634            let catalog = self.catalog.read();
7635            catalog.require_auth || principal.user_id != 0
7636        });
7637        let txn_id = self.alloc_txn_id();
7638        let read = Snapshot::at(self.epoch.visible());
7639        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7640    }
7641
7642    /// Begin a transaction with a specific isolation level.
7643    pub fn begin_with_isolation(
7644        &self,
7645        level: crate::txn::IsolationLevel,
7646    ) -> crate::txn::Transaction<'_> {
7647        let txn_id = self.alloc_txn_id();
7648        let epoch = match level {
7649            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
7650            _ => self.epoch.visible(),
7651        };
7652        let read = Snapshot::at(epoch);
7653        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7654        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7655    }
7656
7657    /// Begin a transaction whose trigger programs may route external-table DML
7658    /// through an application/query-layer module bridge.
7659    pub fn begin_with_external_trigger_bridge<'a>(
7660        &'a self,
7661        bridge: &'a dyn ExternalTriggerBridge,
7662    ) -> crate::txn::Transaction<'a> {
7663        let txn_id = self.alloc_txn_id();
7664        let read = Snapshot::at(self.epoch.visible());
7665        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7666        crate::txn::Transaction::new(self, txn_id, read)
7667            .with_external_trigger_bridge(bridge)
7668            .with_principal(principal, catalog_bound)
7669    }
7670
7671    pub fn begin_with_external_trigger_bridge_as<'a>(
7672        &'a self,
7673        bridge: &'a dyn ExternalTriggerBridge,
7674        principal: Option<crate::auth::Principal>,
7675    ) -> crate::txn::Transaction<'a> {
7676        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7677            let catalog = self.catalog.read();
7678            catalog.require_auth || principal.user_id != 0
7679        });
7680        let txn_id = self.alloc_txn_id();
7681        let read = Snapshot::at(self.epoch.visible());
7682        crate::txn::Transaction::new(self, txn_id, read)
7683            .with_external_trigger_bridge(bridge)
7684            .with_principal(principal, catalog_bound)
7685    }
7686
7687    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
7688    pub fn transaction<T>(
7689        &self,
7690        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7691    ) -> Result<T> {
7692        let mut tx = self.begin();
7693        match f(&mut tx) {
7694            Ok(out) => {
7695                tx.commit()?;
7696                Ok(out)
7697            }
7698            Err(e) => {
7699                tx.rollback();
7700                Err(e)
7701            }
7702        }
7703    }
7704
7705    pub fn transaction_with_row_ids<T>(
7706        &self,
7707        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7708    ) -> Result<(T, Vec<RowId>)> {
7709        let mut tx = self.begin();
7710        match f(&mut tx) {
7711            Ok(output) => {
7712                let (_, row_ids) = tx.commit_with_row_ids()?;
7713                Ok((output, row_ids))
7714            }
7715            Err(error) => {
7716                tx.rollback();
7717                Err(error)
7718            }
7719        }
7720    }
7721
7722    pub fn transaction_for_current_principal<T>(
7723        &self,
7724        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7725    ) -> Result<T> {
7726        if self.principal.read().is_some() {
7727            self.refresh_principal()?;
7728        }
7729        let mut transaction = self.begin_as(self.principal.read().clone());
7730        match f(&mut transaction) {
7731            Ok(output) => {
7732                transaction.commit()?;
7733                Ok(output)
7734            }
7735            Err(error) => {
7736                transaction.rollback();
7737                Err(error)
7738            }
7739        }
7740    }
7741
7742    pub fn transaction_for_current_principal_with_epoch<T>(
7743        &self,
7744        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7745    ) -> Result<(Epoch, T)> {
7746        if self.principal.read().is_some() {
7747            self.refresh_principal()?;
7748        }
7749        let mut transaction = self.begin_as(self.principal.read().clone());
7750        match f(&mut transaction) {
7751            Ok(output) => {
7752                let epoch = transaction.commit()?;
7753                Ok((epoch, output))
7754            }
7755            Err(error) => {
7756                transaction.rollback();
7757                Err(error)
7758            }
7759        }
7760    }
7761
7762    pub fn transaction_with_row_ids_for_current_principal<T>(
7763        &self,
7764        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7765    ) -> Result<(T, Vec<RowId>)> {
7766        if self.principal.read().is_some() {
7767            self.refresh_principal()?;
7768        }
7769        let mut transaction = self.begin_as(self.principal.read().clone());
7770        match f(&mut transaction) {
7771            Ok(output) => {
7772                let (_, row_ids) = transaction.commit_with_row_ids()?;
7773                Ok((output, row_ids))
7774            }
7775            Err(error) => {
7776                transaction.rollback();
7777                Err(error)
7778            }
7779        }
7780    }
7781
7782    /// Run `f` in a transaction with an external-trigger bridge; commit on
7783    /// `Ok`, rollback on `Err`.
7784    pub fn transaction_with_external_trigger_bridge<'a, T>(
7785        &'a self,
7786        bridge: &'a dyn ExternalTriggerBridge,
7787        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7788    ) -> Result<T> {
7789        let mut tx = self.begin_with_external_trigger_bridge(bridge);
7790        match f(&mut tx) {
7791            Ok(out) => {
7792                tx.commit()?;
7793                Ok(out)
7794            }
7795            Err(e) => {
7796                tx.rollback();
7797                Err(e)
7798            }
7799        }
7800    }
7801
7802    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
7803        &'a self,
7804        bridge: &'a dyn ExternalTriggerBridge,
7805        principal: Option<crate::auth::Principal>,
7806        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7807    ) -> Result<T> {
7808        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
7809        match f(&mut tx) {
7810            Ok(output) => {
7811                tx.commit()?;
7812                Ok(output)
7813            }
7814            Err(error) => {
7815                tx.rollback();
7816                Err(error)
7817            }
7818        }
7819    }
7820
7821    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
7822    /// `Transaction::new` so registration happens **before** any read.
7823    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
7824        self.active_txns.register(epoch)
7825    }
7826
7827    fn fill_auto_increment_for_staging(
7828        &self,
7829        staging: &mut [(u64, crate::txn::Staged)],
7830        control: Option<&crate::ExecutionControl>,
7831    ) -> Result<()> {
7832        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
7833        for (index, (table_id, staged)) in staging.iter().enumerate() {
7834            commit_prepare_checkpoint(control, index)?;
7835            if matches!(staged, crate::txn::Staged::Put(_)) {
7836                puts_by_table.entry(*table_id).or_default().push(index);
7837            }
7838        }
7839
7840        let tables = self.tables.read();
7841        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
7842            commit_prepare_checkpoint(control, table_index)?;
7843            if let Some(handle) = tables.get(&table_id) {
7844                #[cfg(test)]
7845                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
7846                let mut t = handle.lock();
7847                for (fill_index, index) in indexes.into_iter().enumerate() {
7848                    commit_prepare_checkpoint(control, fill_index)?;
7849                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
7850                        t.fill_auto_inc(cells)?;
7851                    }
7852                }
7853            }
7854        }
7855        Ok(())
7856    }
7857
7858    fn expand_table_triggers(
7859        &self,
7860        staging: &mut Vec<(u64, crate::txn::Staged)>,
7861        read_epoch: Epoch,
7862        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
7863        external_states: &mut Vec<(String, Vec<u8>)>,
7864        control: Option<&crate::ExecutionControl>,
7865    ) -> Result<()> {
7866        commit_prepare_checkpoint(control, 0)?;
7867        let mut external_writes = Vec::new();
7868        let config = self.trigger_config();
7869        if config.recursive_triggers {
7870            let chunk = std::mem::take(staging);
7871            let stacks = vec![Vec::new(); chunk.len()];
7872            *staging = self.expand_trigger_chunk(
7873                chunk,
7874                stacks,
7875                read_epoch,
7876                0,
7877                config.max_depth,
7878                &mut external_writes,
7879                &config,
7880                control,
7881            )?;
7882            self.apply_external_trigger_writes(
7883                external_writes,
7884                external_trigger_bridge,
7885                external_states,
7886                staging,
7887                control,
7888            )?;
7889            return Ok(());
7890        }
7891
7892        let mut expansion =
7893            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
7894        if !expansion.before.is_empty() {
7895            let mut final_staging = expansion.before;
7896            final_staging.extend(filter_ignored_staging(
7897                std::mem::take(staging),
7898                &expansion.ignored_indices,
7899            ));
7900            *staging = final_staging;
7901        } else if !expansion.ignored_indices.is_empty() {
7902            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
7903        }
7904        staging.append(&mut expansion.after);
7905        external_writes.append(&mut expansion.before_external);
7906        external_writes.append(&mut expansion.after_external);
7907        self.apply_external_trigger_writes(
7908            external_writes,
7909            external_trigger_bridge,
7910            external_states,
7911            staging,
7912            control,
7913        )?;
7914        Ok(())
7915    }
7916
7917    #[allow(clippy::too_many_arguments)]
7918    fn expand_trigger_chunk(
7919        &self,
7920        mut chunk: Vec<(u64, crate::txn::Staged)>,
7921        stacks: Vec<Vec<String>>,
7922        read_epoch: Epoch,
7923        depth: u32,
7924        max_depth: u32,
7925        external_writes: &mut Vec<ExternalTriggerWrite>,
7926        config: &TriggerConfig,
7927        control: Option<&crate::ExecutionControl>,
7928    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
7929        if chunk.is_empty() {
7930            return Ok(Vec::new());
7931        }
7932        commit_prepare_checkpoint(control, 0)?;
7933        self.fill_auto_increment_for_staging(&mut chunk, control)?;
7934        let expansion = self.expand_table_triggers_once(
7935            &mut chunk,
7936            read_epoch,
7937            Some(&stacks),
7938            config,
7939            control,
7940        )?;
7941        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
7942            let stack = expansion
7943                .before_stacks
7944                .first()
7945                .or_else(|| expansion.after_stacks.first())
7946                .cloned()
7947                .unwrap_or_default();
7948            return Err(MongrelError::TriggerValidation(format!(
7949                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
7950                Self::format_trigger_stack(&stack)
7951            )));
7952        }
7953
7954        let mut out = Vec::new();
7955        external_writes.extend(expansion.before_external);
7956        out.extend(self.expand_trigger_chunk(
7957            expansion.before,
7958            expansion.before_stacks,
7959            read_epoch,
7960            depth + 1,
7961            max_depth,
7962            external_writes,
7963            config,
7964            control,
7965        )?);
7966        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
7967        external_writes.extend(expansion.after_external);
7968        out.extend(self.expand_trigger_chunk(
7969            expansion.after,
7970            expansion.after_stacks,
7971            read_epoch,
7972            depth + 1,
7973            max_depth,
7974            external_writes,
7975            config,
7976            control,
7977        )?);
7978        Ok(out)
7979    }
7980
7981    fn apply_external_trigger_writes(
7982        &self,
7983        writes: Vec<ExternalTriggerWrite>,
7984        bridge: Option<&dyn ExternalTriggerBridge>,
7985        external_states: &mut Vec<(String, Vec<u8>)>,
7986        staging: &mut Vec<(u64, crate::txn::Staged)>,
7987        control: Option<&crate::ExecutionControl>,
7988    ) -> Result<()> {
7989        if writes.is_empty() {
7990            return Ok(());
7991        }
7992        let bridge = bridge.ok_or_else(|| {
7993            MongrelError::TriggerValidation(
7994                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
7995            )
7996        })?;
7997        for (write_index, write) in writes.into_iter().enumerate() {
7998            commit_prepare_checkpoint(control, write_index)?;
7999            let table = write.table().to_string();
8000            let entry = self.external_table(&table).ok_or_else(|| {
8001                MongrelError::NotFound(format!("external table {table:?} not found"))
8002            })?;
8003            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
8004            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
8005            external_states.push((table, result.state));
8006            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
8007                commit_prepare_checkpoint(control, base_index)?;
8008                match base_write {
8009                    ExternalTriggerBaseWrite::Put { table, cells } => {
8010                        let table_id = self.table_id(&table)?;
8011                        staging.push((table_id, crate::txn::Staged::Put(cells)));
8012                    }
8013                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
8014                        let table_id = self.table_id(&table)?;
8015                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
8016                    }
8017                }
8018            }
8019        }
8020        dedup_external_states_in_place(external_states);
8021        Ok(())
8022    }
8023
8024    fn expand_table_triggers_once(
8025        &self,
8026        staging: &mut Vec<(u64, crate::txn::Staged)>,
8027        read_epoch: Epoch,
8028        trigger_stacks: Option<&[Vec<String>]>,
8029        config: &TriggerConfig,
8030        control: Option<&crate::ExecutionControl>,
8031    ) -> Result<TriggerExpansion> {
8032        commit_prepare_checkpoint(control, 0)?;
8033        let triggers: Vec<StoredTrigger> = self
8034            .catalog
8035            .read()
8036            .triggers
8037            .iter()
8038            .filter(|entry| {
8039                entry.trigger.enabled
8040                    && matches!(
8041                        entry.trigger.timing,
8042                        TriggerTiming::Before | TriggerTiming::After
8043                    )
8044                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
8045            })
8046            .map(|entry| entry.trigger.clone())
8047            .collect();
8048        if triggers.is_empty() || staging.is_empty() {
8049            return Ok(TriggerExpansion::default());
8050        }
8051
8052        let before_triggers = triggers
8053            .iter()
8054            .filter(|trigger| trigger.timing == TriggerTiming::Before)
8055            .cloned()
8056            .collect::<Vec<_>>();
8057        let after_triggers = triggers
8058            .iter()
8059            .filter(|trigger| trigger.timing == TriggerTiming::After)
8060            .cloned()
8061            .collect::<Vec<_>>();
8062
8063        let mut before_added = Vec::new();
8064        let mut before_stacks = Vec::new();
8065        let mut before_external = Vec::new();
8066        let mut ignored_indices = std::collections::BTreeSet::new();
8067        if !before_triggers.is_empty() {
8068            let before_events =
8069                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
8070            let mut out = TriggerProgramOutput {
8071                added: &mut before_added,
8072                added_stacks: &mut before_stacks,
8073                added_external: &mut before_external,
8074                ignored_indices: &mut ignored_indices,
8075            };
8076            self.execute_triggers_for_events(
8077                &before_triggers,
8078                &before_events,
8079                Some(staging),
8080                &mut out,
8081                config,
8082                read_epoch,
8083                control,
8084            )?;
8085        }
8086
8087        let after_events = if after_triggers.is_empty() {
8088            Vec::new()
8089        } else {
8090            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
8091                .into_iter()
8092                .filter(|event| {
8093                    !event
8094                        .op_indices
8095                        .iter()
8096                        .any(|idx| ignored_indices.contains(idx))
8097                })
8098                .collect()
8099        };
8100
8101        let mut after_added = Vec::new();
8102        let mut after_stacks = Vec::new();
8103        let mut after_external = Vec::new();
8104        let mut out = TriggerProgramOutput {
8105            added: &mut after_added,
8106            added_stacks: &mut after_stacks,
8107            added_external: &mut after_external,
8108            ignored_indices: &mut ignored_indices,
8109        };
8110        self.execute_triggers_for_events(
8111            &after_triggers,
8112            &after_events,
8113            None,
8114            &mut out,
8115            config,
8116            read_epoch,
8117            control,
8118        )?;
8119        Ok(TriggerExpansion {
8120            before: before_added,
8121            before_stacks,
8122            before_external,
8123            after: after_added,
8124            after_stacks,
8125            after_external,
8126            ignored_indices,
8127        })
8128    }
8129
8130    #[allow(clippy::too_many_arguments)]
8131    fn execute_triggers_for_events(
8132        &self,
8133        triggers: &[StoredTrigger],
8134        events: &[WriteEvent],
8135        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8136        out: &mut TriggerProgramOutput<'_>,
8137        config: &TriggerConfig,
8138        read_epoch: Epoch,
8139        control: Option<&crate::ExecutionControl>,
8140    ) -> Result<()> {
8141        let mut checkpoint_index = 0_usize;
8142        for event in events {
8143            for trigger in triggers {
8144                commit_prepare_checkpoint(control, checkpoint_index)?;
8145                checkpoint_index += 1;
8146                if event
8147                    .op_indices
8148                    .iter()
8149                    .any(|idx| out.ignored_indices.contains(idx))
8150                {
8151                    break;
8152                }
8153                let matches = {
8154                    let cat = self.catalog.read();
8155                    trigger_matches_event(trigger, event, &cat)?
8156                };
8157                if !matches {
8158                    continue;
8159                }
8160                if let Some(when) = &trigger.when {
8161                    if !eval_trigger_expr(when, event)? {
8162                        continue;
8163                    }
8164                }
8165                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
8166                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
8167                    return Err(MongrelError::TriggerValidation(format!(
8168                        "trigger recursion cycle detected; trigger stack: {}",
8169                        Self::format_trigger_stack(&trigger_stack)
8170                    )));
8171                }
8172                let outcome = match staging.as_mut() {
8173                    Some(staging) => self.execute_trigger_program(
8174                        trigger,
8175                        event,
8176                        Some(&mut **staging),
8177                        out,
8178                        &trigger_stack,
8179                        config,
8180                        read_epoch,
8181                        control,
8182                    )?,
8183                    None => self.execute_trigger_program(
8184                        trigger,
8185                        event,
8186                        None,
8187                        out,
8188                        &trigger_stack,
8189                        config,
8190                        read_epoch,
8191                        control,
8192                    )?,
8193                };
8194                if outcome == TriggerProgramOutcome::Ignore {
8195                    out.ignored_indices.extend(event.op_indices.iter().copied());
8196                    break;
8197                }
8198            }
8199        }
8200        Ok(())
8201    }
8202
8203    fn trigger_events_for_staging(
8204        &self,
8205        staging: &[(u64, crate::txn::Staged)],
8206        read_epoch: Epoch,
8207        trigger_stacks: Option<&[Vec<String>]>,
8208        control: Option<&crate::ExecutionControl>,
8209    ) -> Result<Vec<WriteEvent>> {
8210        use crate::txn::Staged;
8211        use std::collections::{HashMap, VecDeque};
8212
8213        let snapshot = Snapshot::at(read_epoch);
8214        let cat = self.catalog.read();
8215        let mut table_names = HashMap::new();
8216        let mut table_schemas = HashMap::new();
8217        for entry in cat
8218            .tables
8219            .iter()
8220            .filter(|entry| matches!(entry.state, TableState::Live))
8221        {
8222            table_names.insert(entry.table_id, entry.name.clone());
8223            table_schemas.insert(entry.table_id, entry.schema.clone());
8224        }
8225        drop(cat);
8226
8227        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
8228        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
8229        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
8230
8231        for (idx, (table_id, staged)) in staging.iter().enumerate() {
8232            commit_prepare_checkpoint(control, idx)?;
8233            let Some(schema) = table_schemas.get(table_id) else {
8234                continue;
8235            };
8236            let Some(pk) = schema.primary_key() else {
8237                continue;
8238            };
8239            match staged {
8240                Staged::Delete(row_id) => {
8241                    let handle = self.table_by_id(*table_id)?;
8242                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
8243                        continue;
8244                    };
8245                    let Some(pk_value) = row.columns.get(&pk.id) else {
8246                        continue;
8247                    };
8248                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
8249                    delete_by_key
8250                        .entry((*table_id, pk_value.encode_key()))
8251                        .or_default()
8252                        .push_back(idx);
8253                }
8254                Staged::Put(cells) => {
8255                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
8256                        put_by_key
8257                            .entry((*table_id, value.encode_key()))
8258                            .or_default()
8259                            .push_back(idx);
8260                    }
8261                }
8262                Staged::Update { row_id, .. } => {
8263                    let handle = self.table_by_id(*table_id)?;
8264                    let row = handle.lock().get(*row_id, snapshot);
8265                    if let Some(row) = row {
8266                        old_rows.insert(idx, TriggerRowImage::from_row(row));
8267                    }
8268                }
8269                Staged::Truncate => {}
8270            }
8271        }
8272
8273        let mut paired_delete = std::collections::HashSet::new();
8274        let mut paired_put = std::collections::HashSet::new();
8275        let mut events = Vec::new();
8276
8277        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
8278            commit_prepare_checkpoint(control, pair_index)?;
8279            let Some(puts) = put_by_key.get_mut(key) else {
8280                continue;
8281            };
8282            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
8283                paired_delete.insert(delete_idx);
8284                paired_put.insert(put_idx);
8285                let (table_id, _) = &staging[put_idx];
8286                let Some(table_name) = table_names.get(table_id).cloned() else {
8287                    continue;
8288                };
8289                let old = old_rows.get(&delete_idx).cloned();
8290                let new = match &staging[put_idx].1 {
8291                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
8292                    _ => None,
8293                };
8294                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
8295                events.push(WriteEvent {
8296                    table: table_name,
8297                    kind: TriggerEvent::Update,
8298                    old,
8299                    new,
8300                    changed_columns,
8301                    op_indices: vec![delete_idx, put_idx],
8302                    put_idx: Some(put_idx),
8303                    trigger_stack: Self::trigger_stack_for_indices(
8304                        trigger_stacks,
8305                        &[delete_idx, put_idx],
8306                    ),
8307                });
8308            }
8309        }
8310
8311        for (idx, (table_id, staged)) in staging.iter().enumerate() {
8312            commit_prepare_checkpoint(control, idx)?;
8313            let Some(table_name) = table_names.get(table_id).cloned() else {
8314                continue;
8315            };
8316            match staged {
8317                Staged::Put(cells) if !paired_put.contains(&idx) => {
8318                    let new = Some(TriggerRowImage::from_cells(cells));
8319                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
8320                    events.push(WriteEvent {
8321                        table: table_name,
8322                        kind: TriggerEvent::Insert,
8323                        old: None,
8324                        new,
8325                        changed_columns,
8326                        op_indices: vec![idx],
8327                        put_idx: Some(idx),
8328                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8329                    });
8330                }
8331                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
8332                    let old = match old_rows.get(&idx).cloned() {
8333                        Some(old) => Some(old),
8334                        None => {
8335                            let handle = self.table_by_id(*table_id)?;
8336                            let row = handle.lock().get(*row_id, snapshot);
8337                            row.map(TriggerRowImage::from_row)
8338                        }
8339                    };
8340                    let Some(old) = old else {
8341                        continue;
8342                    };
8343                    let changed_columns = old.columns.keys().copied().collect();
8344                    events.push(WriteEvent {
8345                        table: table_name,
8346                        kind: TriggerEvent::Delete,
8347                        old: Some(old),
8348                        new: None,
8349                        changed_columns,
8350                        op_indices: vec![idx],
8351                        put_idx: None,
8352                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8353                    });
8354                }
8355                Staged::Update { new_row: cells, .. } => {
8356                    let old = old_rows.get(&idx).cloned();
8357                    let new = Some(TriggerRowImage::from_cells(cells));
8358                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
8359                    events.push(WriteEvent {
8360                        table: table_name,
8361                        kind: TriggerEvent::Update,
8362                        old,
8363                        new,
8364                        changed_columns,
8365                        op_indices: vec![idx],
8366                        put_idx: Some(idx),
8367                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8368                    });
8369                }
8370                Staged::Truncate => {}
8371                _ => {}
8372            }
8373        }
8374
8375        Ok(events)
8376    }
8377
8378    #[allow(clippy::too_many_arguments)]
8379    fn execute_trigger_program(
8380        &self,
8381        trigger: &StoredTrigger,
8382        event: &WriteEvent,
8383        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8384        out: &mut TriggerProgramOutput<'_>,
8385        trigger_stack: &[String],
8386        config: &TriggerConfig,
8387        read_epoch: Epoch,
8388        control: Option<&crate::ExecutionControl>,
8389    ) -> Result<TriggerProgramOutcome> {
8390        let mut event = event.clone();
8391        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
8392        self.execute_trigger_steps(
8393            trigger,
8394            &trigger.program.steps,
8395            &mut event,
8396            staging,
8397            out,
8398            trigger_stack,
8399            config,
8400            &mut select_results,
8401            0,
8402            None,
8403            read_epoch,
8404            control,
8405        )
8406    }
8407
8408    #[allow(clippy::too_many_arguments)]
8409    fn execute_trigger_steps(
8410        &self,
8411        trigger: &StoredTrigger,
8412        steps: &[TriggerStep],
8413        event: &mut WriteEvent,
8414        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8415        out: &mut TriggerProgramOutput<'_>,
8416        trigger_stack: &[String],
8417        config: &TriggerConfig,
8418        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
8419        depth: u32,
8420        selected: Option<&TriggerRowImage>,
8421        read_epoch: Epoch,
8422        control: Option<&crate::ExecutionControl>,
8423    ) -> Result<TriggerProgramOutcome> {
8424        let _ = depth;
8425        for (step_index, step) in steps.iter().enumerate() {
8426            commit_prepare_checkpoint(control, step_index)?;
8427            match step {
8428                TriggerStep::SetNew { cells } => {
8429                    if trigger.timing != TriggerTiming::Before {
8430                        return Err(MongrelError::InvalidArgument(
8431                            "SetNew trigger step is only valid in BEFORE triggers".into(),
8432                        ));
8433                    }
8434                    let put_idx = event.put_idx.ok_or_else(|| {
8435                        MongrelError::InvalidArgument(
8436                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
8437                        )
8438                    })?;
8439                    let staging = staging.as_deref_mut().ok_or_else(|| {
8440                        MongrelError::InvalidArgument(
8441                            "SetNew trigger step requires mutable trigger staging".into(),
8442                        )
8443                    })?;
8444                    let mut update_changed_columns = None;
8445                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
8446                        Some(crate::txn::Staged::Put(cells)) => cells,
8447                        Some(crate::txn::Staged::Update {
8448                            new_row,
8449                            changed_columns,
8450                            ..
8451                        }) => {
8452                            update_changed_columns = Some(changed_columns);
8453                            new_row
8454                        }
8455                        _ => {
8456                            return Err(MongrelError::InvalidArgument(
8457                                "SetNew trigger step target row is not mutable".into(),
8458                            ))
8459                        }
8460                    };
8461                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
8462                        row_cells.retain(|(id, _)| *id != column_id);
8463                        row_cells.push((column_id, value.clone()));
8464                        if let Some(changed_columns) = &mut update_changed_columns {
8465                            changed_columns.push(column_id);
8466                        }
8467                        if let Some(new) = &mut event.new {
8468                            new.columns.insert(column_id, value);
8469                        }
8470                    }
8471                    row_cells.sort_by_key(|(id, _)| *id);
8472                    if let Some(changed_columns) = update_changed_columns {
8473                        changed_columns.sort_unstable();
8474                        changed_columns.dedup();
8475                    }
8476                }
8477                TriggerStep::Insert { table, cells } => {
8478                    let cells = eval_trigger_cells(cells, event, selected)?;
8479                    if let Ok(table_id) = self.table_id(table) {
8480                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
8481                        out.added_stacks.push(trigger_stack.to_vec());
8482                    } else if self.external_table(table).is_some() {
8483                        out.added_external.push(ExternalTriggerWrite::Insert {
8484                            table: table.clone(),
8485                            cells,
8486                        });
8487                    } else {
8488                        return Err(MongrelError::NotFound(format!(
8489                            "trigger {:?} insert target {table:?} not found",
8490                            trigger.name
8491                        )));
8492                    }
8493                }
8494                TriggerStep::UpdateByPk { table, pk, cells } => {
8495                    let pk = eval_trigger_value(pk, event, selected)?;
8496                    let cells = eval_trigger_cells(cells, event, selected)?;
8497                    if self.external_table(table).is_some() {
8498                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
8499                            table: table.clone(),
8500                            pk,
8501                            cells,
8502                        });
8503                    } else {
8504                        let row_id = self
8505                            .table(table)?
8506                            .lock()
8507                            .lookup_pk(&pk.encode_key())
8508                            .ok_or_else(|| {
8509                                MongrelError::NotFound(format!(
8510                                    "trigger {:?} update target not found",
8511                                    trigger.name
8512                                ))
8513                            })?;
8514                        let handle = self.table(table)?;
8515                        let snapshot = Snapshot::at(self.epoch.visible());
8516                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
8517                            MongrelError::NotFound(format!(
8518                                "trigger {:?} update target not visible",
8519                                trigger.name
8520                            ))
8521                        })?;
8522                        let mut changed_columns = cells
8523                            .iter()
8524                            .map(|(column_id, _)| *column_id)
8525                            .collect::<Vec<_>>();
8526                        changed_columns.sort_unstable();
8527                        changed_columns.dedup();
8528                        let mut merged = old.columns;
8529                        for (column_id, value) in cells {
8530                            merged.insert(column_id, value);
8531                        }
8532                        out.added.push((
8533                            self.table_id(table)?,
8534                            crate::txn::Staged::Update {
8535                                row_id,
8536                                new_row: merged.into_iter().collect(),
8537                                changed_columns,
8538                            },
8539                        ));
8540                        out.added_stacks.push(trigger_stack.to_vec());
8541                    }
8542                }
8543                TriggerStep::DeleteByPk { table, pk } => {
8544                    let pk = eval_trigger_value(pk, event, selected)?;
8545                    if self.external_table(table).is_some() {
8546                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
8547                            table: table.clone(),
8548                            pk,
8549                        });
8550                    } else {
8551                        let row_id = self
8552                            .table(table)?
8553                            .lock()
8554                            .lookup_pk(&pk.encode_key())
8555                            .ok_or_else(|| {
8556                                MongrelError::NotFound(format!(
8557                                    "trigger {:?} delete target not found",
8558                                    trigger.name
8559                                ))
8560                            })?;
8561                        out.added
8562                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
8563                        out.added_stacks.push(trigger_stack.to_vec());
8564                    }
8565                }
8566                TriggerStep::Select {
8567                    id,
8568                    table,
8569                    conditions,
8570                } => {
8571                    let schema = self.table(table)?.lock().schema().clone();
8572                    let snapshot = Snapshot::at(read_epoch);
8573                    let handle = self.table(table)?;
8574                    let rows = match control {
8575                        Some(control) => {
8576                            handle.lock().visible_rows_controlled(snapshot, control)?
8577                        }
8578                        None => handle.lock().visible_rows(snapshot)?,
8579                    };
8580                    let mut matched = Vec::new();
8581                    for (row_index, row) in rows.into_iter().enumerate() {
8582                        commit_prepare_checkpoint(control, row_index)?;
8583                        let image = TriggerRowImage::from_row(row);
8584                        let passes = conditions
8585                            .iter()
8586                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8587                            .collect::<Result<Vec<_>>>()?
8588                            .into_iter()
8589                            .all(|b| b);
8590                        if passes {
8591                            matched.push(image);
8592                        }
8593                    }
8594                    if let Some(pk) = schema.primary_key() {
8595                        matched.sort_by(|a, b| {
8596                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
8597                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
8598                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
8599                        });
8600                    }
8601                    select_results.insert(id.clone(), matched);
8602                }
8603                TriggerStep::Foreach { id, steps } => {
8604                    let rows = select_results.get(id).ok_or_else(|| {
8605                        MongrelError::InvalidArgument(format!(
8606                            "trigger {:?} foreach references unknown select id {id:?}",
8607                            trigger.name
8608                        ))
8609                    })?;
8610                    if rows.len() > config.max_loop_iterations as usize {
8611                        return Err(MongrelError::InvalidArgument(format!(
8612                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
8613                            trigger.name, config.max_loop_iterations
8614                        )));
8615                    }
8616                    for (row_index, row) in rows.clone().into_iter().enumerate() {
8617                        commit_prepare_checkpoint(control, row_index)?;
8618                        let result = self.execute_trigger_steps(
8619                            trigger,
8620                            steps,
8621                            event,
8622                            staging.as_deref_mut(),
8623                            out,
8624                            trigger_stack,
8625                            config,
8626                            select_results,
8627                            depth + 1,
8628                            Some(&row),
8629                            read_epoch,
8630                            control,
8631                        )?;
8632                        if result == TriggerProgramOutcome::Ignore {
8633                            return Ok(TriggerProgramOutcome::Ignore);
8634                        }
8635                    }
8636                }
8637                TriggerStep::DeleteWhere { table, conditions } => {
8638                    let schema = self.table(table)?.lock().schema().clone();
8639                    let snapshot = Snapshot::at(read_epoch);
8640                    let handle = self.table(table)?;
8641                    let rows = match control {
8642                        Some(control) => {
8643                            handle.lock().visible_rows_controlled(snapshot, control)?
8644                        }
8645                        None => handle.lock().visible_rows(snapshot)?,
8646                    };
8647                    let table_id = self.table_id(table)?;
8648                    let mut to_delete = Vec::new();
8649                    for (row_index, row) in rows.into_iter().enumerate() {
8650                        commit_prepare_checkpoint(control, row_index)?;
8651                        let image = TriggerRowImage::from_row(row.clone());
8652                        let passes = conditions
8653                            .iter()
8654                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8655                            .collect::<Result<Vec<_>>>()?
8656                            .into_iter()
8657                            .all(|b| b);
8658                        if passes {
8659                            to_delete.push((table_id, row.row_id));
8660                        }
8661                    }
8662                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
8663                        commit_prepare_checkpoint(control, row_index)?;
8664                        out.added
8665                            .push((table_id, crate::txn::Staged::Delete(row_id)));
8666                        out.added_stacks.push(trigger_stack.to_vec());
8667                    }
8668                }
8669                TriggerStep::UpdateWhere {
8670                    table,
8671                    conditions,
8672                    cells,
8673                } => {
8674                    let schema = self.table(table)?.lock().schema().clone();
8675                    let snapshot = Snapshot::at(read_epoch);
8676                    let handle = self.table(table)?;
8677                    let rows = match control {
8678                        Some(control) => {
8679                            handle.lock().visible_rows_controlled(snapshot, control)?
8680                        }
8681                        None => handle.lock().visible_rows(snapshot)?,
8682                    };
8683                    let table_id = self.table_id(table)?;
8684                    let mut changed_columns =
8685                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
8686                    changed_columns.sort_unstable();
8687                    changed_columns.dedup();
8688                    let mut to_update = Vec::new();
8689                    for (row_index, row) in rows.into_iter().enumerate() {
8690                        commit_prepare_checkpoint(control, row_index)?;
8691                        let image = TriggerRowImage::from_row(row.clone());
8692                        let passes = conditions
8693                            .iter()
8694                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8695                            .collect::<Result<Vec<_>>>()?
8696                            .into_iter()
8697                            .all(|b| b);
8698                        if passes {
8699                            let new_cells = cells
8700                                .iter()
8701                                .map(|cell| {
8702                                    Ok((
8703                                        cell.column_id,
8704                                        eval_trigger_value(&cell.value, event, Some(&image))?,
8705                                    ))
8706                                })
8707                                .collect::<Result<Vec<_>>>()?;
8708                            let mut merged = row.columns.clone();
8709                            for (column_id, value) in new_cells {
8710                                merged.insert(column_id, value);
8711                            }
8712                            to_update.push((table_id, row.row_id, merged));
8713                        }
8714                    }
8715                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
8716                    {
8717                        commit_prepare_checkpoint(control, row_index)?;
8718                        out.added.push((
8719                            table_id,
8720                            crate::txn::Staged::Update {
8721                                row_id,
8722                                new_row: merged.into_iter().collect(),
8723                                changed_columns: changed_columns.clone(),
8724                            },
8725                        ));
8726                        out.added_stacks.push(trigger_stack.to_vec());
8727                    }
8728                }
8729                TriggerStep::Raise { action, message } => match action {
8730                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
8731                    TriggerRaiseAction::Abort
8732                    | TriggerRaiseAction::Fail
8733                    | TriggerRaiseAction::Rollback => {
8734                        let message = eval_trigger_value(message, event, selected)?;
8735                        return Err(MongrelError::TriggerValidation(format!(
8736                            "trigger {:?} raised: {}; trigger stack: {}",
8737                            trigger.name,
8738                            trigger_message(message),
8739                            Self::format_trigger_stack(trigger_stack)
8740                        )));
8741                    }
8742                },
8743            }
8744        }
8745        Ok(TriggerProgramOutcome::Continue)
8746    }
8747
8748    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
8749        let Some(stacks) = stacks else {
8750            return Vec::new();
8751        };
8752        let mut out = Vec::new();
8753        for idx in indices {
8754            let Some(stack) = stacks.get(*idx) else {
8755                continue;
8756            };
8757            for name in stack {
8758                if !out.iter().any(|existing| existing == name) {
8759                    out.push(name.clone());
8760                }
8761            }
8762        }
8763        out
8764    }
8765
8766    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
8767        let mut out = stack.to_vec();
8768        out.push(trigger_name.to_string());
8769        out
8770    }
8771
8772    fn format_trigger_stack(stack: &[String]) -> String {
8773        if stack.is_empty() {
8774            "<root>".into()
8775        } else {
8776            stack.join(" -> ")
8777        }
8778    }
8779
8780    /// Authoritatively validate every declared constraint on the staged write
8781    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
8782    /// SET NULL actions into explicit child ops. Called from
8783    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
8784    /// violation as an `Err`, aborting the commit atomically. This is the
8785    /// server-side authority point: concurrent remote writers that each pass
8786    /// their own client-side checks still cannot both commit a violating batch.
8787    ///
8788    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
8789    /// intra-transaction dedup; concurrent-txn races are additionally caught by
8790    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
8791    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
8792    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
8793    /// RESTRICT-only (cascade-truncate is unsupported).
8794    fn validate_constraints(
8795        &self,
8796        staging: &mut Vec<(u64, crate::txn::Staged)>,
8797        read_epoch: Epoch,
8798        control: Option<&crate::ExecutionControl>,
8799    ) -> Result<()> {
8800        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
8801        use crate::memtable::Row;
8802        use crate::txn::Staged;
8803        use std::collections::HashSet;
8804
8805        commit_prepare_checkpoint(control, 0)?;
8806        let snapshot = Snapshot::at(read_epoch);
8807        let cat = self.catalog.read();
8808
8809        // Collect live (id, name, constraints-bearing?) for staged tables.
8810        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
8811            .tables
8812            .iter()
8813            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
8814            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
8815            .collect();
8816
8817        // Fast path: bail if no live table declares any constraints at all.
8818        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
8819        if !any_constraints {
8820            return Ok(());
8821        }
8822
8823        // Lazily-loaded visible rows per table, shared across checks.
8824        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
8825        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
8826            if let Some(r) = rows_cache.get(&table_id) {
8827                return Ok(r.clone());
8828            }
8829            let handle = self.table_by_id(table_id)?;
8830            let rows = match control {
8831                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
8832                None => handle.lock().visible_rows(snapshot)?,
8833            };
8834            rows_cache.insert(table_id, rows.clone());
8835            Ok(rows)
8836        };
8837
8838        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
8839        // carry an explicit old RowId + full new image. This makes action choice
8840        // reliable even when the referenced key itself changes; a delete+put
8841        // heuristic cannot distinguish that from unrelated operations.
8842        let mut processed_updates = HashSet::new();
8843        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
8844        let mut update_pass = 0_usize;
8845        loop {
8846            commit_prepare_checkpoint(control, update_pass)?;
8847            update_pass += 1;
8848            let updates: Vec<PendingUpdate> = staging
8849                .iter()
8850                .enumerate()
8851                .filter_map(|(index, (table_id, op))| match op {
8852                    Staged::Update {
8853                        row_id,
8854                        new_row: cells,
8855                        ..
8856                    } if !processed_updates.contains(&index) => {
8857                        Some((index, *table_id, *row_id, cells.clone()))
8858                    }
8859                    _ => None,
8860                })
8861                .collect();
8862            if updates.is_empty() {
8863                break;
8864            }
8865            let mut new_ops = Vec::new();
8866            for (update_index, (index, table_id, row_id, new_cells)) in
8867                updates.into_iter().enumerate()
8868            {
8869                commit_prepare_checkpoint(control, update_index)?;
8870                processed_updates.insert(index);
8871                let Some(tname) = live
8872                    .iter()
8873                    .find(|(id, _, _)| *id == table_id)
8874                    .map(|(_, name, _)| *name)
8875                else {
8876                    continue;
8877                };
8878                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
8879                    continue;
8880                };
8881                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
8882                for (child_id, _child_name, child_schema) in &live {
8883                    for fk in &child_schema.constraints.foreign_keys {
8884                        if fk.ref_table != tname {
8885                            continue;
8886                        }
8887                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
8888                        else {
8889                            continue;
8890                        };
8891                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
8892                            == Some(old_key.as_slice())
8893                        {
8894                            continue;
8895                        }
8896                        if fk.on_update == FkAction::Restrict {
8897                            continue;
8898                        }
8899                        let child_rows = load_rows(*child_id)?;
8900                        for (child_index, child) in child_rows.into_iter().enumerate() {
8901                            commit_prepare_checkpoint(control, child_index)?;
8902                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
8903                                != Some(old_key.as_slice())
8904                            {
8905                                continue;
8906                            }
8907                            if staging.iter().any(|(id, op)| {
8908                                *id == *child_id
8909                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
8910                            }) {
8911                                continue;
8912                            }
8913                            let mut cells: Vec<(u16, Value)> = child
8914                                .columns
8915                                .iter()
8916                                .map(|(column_id, value)| (*column_id, value.clone()))
8917                                .collect();
8918                            for (child_column, parent_column) in
8919                                fk.columns.iter().zip(&fk.ref_columns)
8920                            {
8921                                cells.retain(|(column_id, _)| column_id != child_column);
8922                                let value = match fk.on_update {
8923                                    FkAction::Cascade => {
8924                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
8925                                    }
8926                                    FkAction::SetNull => Value::Null,
8927                                    FkAction::Restrict => {
8928                                        return Err(MongrelError::Other(
8929                                            "restricted foreign-key update reached cascade preparation"
8930                                                .into(),
8931                                        ));
8932                                    }
8933                                };
8934                                cells.push((*child_column, value));
8935                            }
8936                            cells.sort_by_key(|(column_id, _)| *column_id);
8937                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
8938                                *id == *child_id
8939                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
8940                            }) {
8941                                if let Staged::Update {
8942                                    new_row: existing,
8943                                    changed_columns,
8944                                    ..
8945                                } = &mut staging[existing_index].1 {
8946                                    changed_columns.extend(fk.columns.iter().copied());
8947                                    changed_columns.sort_unstable();
8948                                    changed_columns.dedup();
8949                                    if *existing != cells {
8950                                        *existing = cells;
8951                                        processed_updates.remove(&existing_index);
8952                                    }
8953                                }
8954                            } else {
8955                                new_ops.push((
8956                                    *child_id,
8957                                    Staged::Update {
8958                                        row_id: child.row_id,
8959                                        new_row: cells,
8960                                        changed_columns: fk.columns.clone(),
8961                                    },
8962                                ));
8963                            }
8964                        }
8965                    }
8966                }
8967            }
8968            staging.extend(new_ops);
8969        }
8970
8971        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
8972        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
8973        // enforced as a violation in Phase B. `cascaded` records every delete
8974        // we have already expanded so a self-referential CASCADE FK cannot loop.
8975        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
8976        let mut cascade_pass = 0_usize;
8977        loop {
8978            commit_prepare_checkpoint(control, cascade_pass)?;
8979            cascade_pass += 1;
8980            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
8981            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
8982                .iter()
8983                .filter_map(|(t, op)| match op {
8984                    Staged::Delete(rid) => Some((*t, *rid)),
8985                    _ => None,
8986                })
8987                .collect();
8988            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
8989                commit_prepare_checkpoint(control, delete_index)?;
8990                if !cascaded.insert((table_id, rid.0)) {
8991                    continue;
8992                }
8993                let Some(tname) = live
8994                    .iter()
8995                    .find(|(t, _, _)| *t == table_id)
8996                    .map(|(_, n, _)| *n)
8997                else {
8998                    continue;
8999                };
9000                let parent_handle = self.table_by_id(table_id)?;
9001                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
9002                    continue;
9003                };
9004                for (child_id, _child_name, child_schema) in &live {
9005                    for fk in &child_schema.constraints.foreign_keys {
9006                        if fk.ref_table != tname {
9007                            continue;
9008                        }
9009                        let Some(parent_key) =
9010                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
9011                        else {
9012                            continue;
9013                        };
9014                        // Suppress ON DELETE cascade/set-null when this "delete"
9015                        // is actually half of an UPDATE encoded as Delete(old)+
9016                        // Put(new): if a staged Put in the SAME table still
9017                        // provides the referenced parent key, the parent still
9018                        // exists (its non-key columns changed) and the children
9019                        // must be left alone. A genuine delete, or an update
9020                        // that CHANGES the referenced key, has no preserving Put
9021                        // → cascade fires as before.
9022                        let key_preserved = staging.iter().any(|(t, op)| {
9023                            if *t != table_id {
9024                                return false;
9025                            }
9026                            let Staged::Put(cells) = op else {
9027                                return false;
9028                            };
9029                            let map: HashMap<u16, crate::memtable::Value> =
9030                                cells.iter().cloned().collect();
9031                            encode_composite_key(&fk.ref_columns, &map).as_deref()
9032                                == Some(parent_key.as_slice())
9033                        });
9034                        if key_preserved {
9035                            continue;
9036                        }
9037                        match fk.on_delete {
9038                            FkAction::Restrict => continue,
9039                            FkAction::Cascade => {
9040                                let child_rows = load_rows(*child_id)?;
9041                                for (child_index, cr) in child_rows.iter().enumerate() {
9042                                    commit_prepare_checkpoint(control, child_index)?;
9043                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
9044                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
9045                                            == Some(parent_key.as_slice())
9046                                    {
9047                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
9048                                    }
9049                                }
9050                            }
9051                            FkAction::SetNull => {
9052                                let child_rows = load_rows(*child_id)?;
9053                                for (child_index, cr) in child_rows.iter().enumerate() {
9054                                    commit_prepare_checkpoint(control, child_index)?;
9055                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
9056                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
9057                                            == Some(parent_key.as_slice())
9058                                    {
9059                                        // Re-emit the child row with the FK
9060                                        // columns set to NULL (delete + put).
9061                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
9062                                            .columns
9063                                            .iter()
9064                                            .map(|(k, v)| (*k, v.clone()))
9065                                            .collect();
9066                                        for cid in &fk.columns {
9067                                            cells.retain(|(k, _)| k != cid);
9068                                            cells.push((*cid, crate::memtable::Value::Null));
9069                                        }
9070                                        new_ops.push((
9071                                            *child_id,
9072                                            Staged::Update {
9073                                                row_id: cr.row_id,
9074                                                new_row: cells,
9075                                                changed_columns: fk.columns.clone(),
9076                                            },
9077                                        ));
9078                                    }
9079                                }
9080                            }
9081                        }
9082                    }
9083                }
9084            }
9085            if new_ops.is_empty() {
9086                break;
9087            }
9088            staging.extend(new_ops);
9089        }
9090
9091        // Rows staged for deletion in THIS transaction (now including cascaded
9092        // deletes). Used to exclude the old version of an updated row from
9093        // unique-existence scans.
9094        let staged_deletes: HashSet<(u64, u64)> = staging
9095            .iter()
9096            .filter_map(|(t, op)| match op {
9097                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
9098                _ => None,
9099            })
9100            .collect();
9101
9102        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
9103        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
9104
9105        // ── Phase B: validate the fully-expanded staging set.
9106        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
9107            commit_prepare_checkpoint(control, operation_index)?;
9108            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
9109            else {
9110                continue;
9111            };
9112            let cells_map: HashMap<u16, crate::memtable::Value>;
9113            match op {
9114                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
9115                    cells_map = cells.iter().cloned().collect();
9116
9117                    // CHECK constraints.
9118                    if !schema.constraints.checks.is_empty() {
9119                        validate_checks(&schema.constraints.checks, &cells_map)?;
9120                    }
9121
9122                    // UNIQUE (non-PK) constraints.
9123                    for uc in &schema.constraints.uniques {
9124                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
9125                            continue; // NULL in a constrained column → skip (SQL).
9126                        };
9127                        let marker = (*table_id, uc.id, key.clone());
9128                        if !seen_unique.insert(marker) {
9129                            return Err(MongrelError::Conflict(format!(
9130                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
9131                                uc.name
9132                            )));
9133                        }
9134                        let rows = load_rows(*table_id)?;
9135                        for (row_index, r) in rows.iter().enumerate() {
9136                            commit_prepare_checkpoint(control, row_index)?;
9137                            // Skip rows this same transaction is deleting (the
9138                            // old version of an updated/cascade-deleted row).
9139                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
9140                                continue;
9141                            }
9142                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
9143                                if theirs == key {
9144                                    return Err(MongrelError::Conflict(format!(
9145                                        "UNIQUE constraint '{}' on table '{tname}' violated",
9146                                        uc.name
9147                                    )));
9148                                }
9149                            }
9150                        }
9151                    }
9152
9153                    // FK insert-side: parent must exist.
9154                    for fk in &schema.constraints.foreign_keys {
9155                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
9156                            continue; // NULL FK component → not checked (SQL).
9157                        };
9158                        let Some(parent_id) = cat
9159                            .tables
9160                            .iter()
9161                            .find(|t| t.name == fk.ref_table)
9162                            .map(|t| t.table_id)
9163                        else {
9164                            return Err(MongrelError::InvalidArgument(format!(
9165                                "FOREIGN KEY '{}' references unknown table '{}'",
9166                                fk.name, fk.ref_table
9167                            )));
9168                        };
9169                        let parent_rows = load_rows(parent_id)?;
9170                        let mut found = false;
9171                        for (row_index, r) in parent_rows.iter().enumerate() {
9172                            commit_prepare_checkpoint(control, row_index)?;
9173                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
9174                                continue;
9175                            }
9176                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
9177                                if pkey == child_key {
9178                                    found = true;
9179                                    break;
9180                                }
9181                            }
9182                        }
9183                        // Final-write-set FK validation: a parent inserted in
9184                        // THIS transaction also satisfies the FK. This enables
9185                        // atomic parent+child batches and cyclical/mutual FK
9186                        // inserts within a single transaction — the child sees
9187                        // the staged parent put even though it is not committed
9188                        // yet.
9189                        if !found {
9190                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
9191                                commit_prepare_checkpoint(control, staged_index)?;
9192                                if *st_table != parent_id {
9193                                    continue;
9194                                }
9195                                if let Staged::Put(pcells)
9196                                | Staged::Update {
9197                                    new_row: pcells, ..
9198                                } = st_op
9199                                {
9200                                    let pmap: HashMap<u16, crate::memtable::Value> =
9201                                        pcells.iter().cloned().collect();
9202                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
9203                                    {
9204                                        if pkey == child_key {
9205                                            found = true;
9206                                            break;
9207                                        }
9208                                    }
9209                                }
9210                            }
9211                        }
9212                        if !found {
9213                            return Err(MongrelError::Conflict(format!(
9214                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
9215                                fk.name, fk.ref_table
9216                            )));
9217                        }
9218                    }
9219
9220                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
9221                    // expanded in Phase A; here the final child write set is
9222                    // known, so a child explicitly moved/deleted by this same
9223                    // transaction does not cause a false violation.
9224                    if let Staged::Update { row_id, .. } = op {
9225                        let parent_handle = self.table_by_id(*table_id)?;
9226                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
9227                            continue;
9228                        };
9229                        for (child_id, child_name, child_schema) in &live {
9230                            for fk in &child_schema.constraints.foreign_keys {
9231                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
9232                                    continue;
9233                                }
9234                                let Some(old_key) =
9235                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
9236                                else {
9237                                    continue;
9238                                };
9239                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
9240                                    == Some(old_key.as_slice())
9241                                {
9242                                    continue;
9243                                }
9244                                for (child_index, child) in
9245                                    load_rows(*child_id)?.into_iter().enumerate()
9246                                {
9247                                    commit_prepare_checkpoint(control, child_index)?;
9248                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
9249                                        != Some(old_key.as_slice())
9250                                    {
9251                                        continue;
9252                                    }
9253                                    let replacement = staging.iter().find_map(|(id, op)| {
9254                                        if *id != *child_id {
9255                                            return None;
9256                                        }
9257                                        match op {
9258                                            Staged::Delete(id) if *id == child.row_id => Some(None),
9259                                            Staged::Update {
9260                                                row_id,
9261                                                new_row: cells,
9262                                                ..
9263                                            } if *row_id == child.row_id => {
9264                                                let map: HashMap<u16, Value> =
9265                                                    cells.iter().cloned().collect();
9266                                                Some(encode_composite_key(&fk.columns, &map))
9267                                            }
9268                                            _ => None,
9269                                        }
9270                                    });
9271                                    if replacement.is_some_and(|key| {
9272                                        key.as_deref() != Some(old_key.as_slice())
9273                                    }) {
9274                                        continue;
9275                                    }
9276                                    return Err(MongrelError::Conflict(format!(
9277                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
9278                                        fk.name
9279                                    )));
9280                                }
9281                            }
9282                        }
9283                    }
9284                }
9285                Staged::Delete(rid) => {
9286                    // FK ON DELETE RESTRICT: a child row (whose FK action is
9287                    // RESTRICT) referencing this parent blocks the delete.
9288                    // CASCADE/SET NULL children were expanded in Phase A.
9289                    let parent_handle = self.table_by_id(*table_id)?;
9290                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
9291                        continue;
9292                    };
9293                    for (child_id, child_name, child_schema) in &live {
9294                        for fk in &child_schema.constraints.foreign_keys {
9295                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
9296                                continue;
9297                            }
9298                            let Some(parent_key) =
9299                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
9300                            else {
9301                                continue;
9302                            };
9303                            let child_rows = load_rows(*child_id)?;
9304                            for (row_index, r) in child_rows.iter().enumerate() {
9305                                commit_prepare_checkpoint(control, row_index)?;
9306                                // A child already being deleted by this txn
9307                                // (cascade/inline) is not a restrict violation.
9308                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
9309                                    continue;
9310                                }
9311                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
9312                                    if ck == parent_key {
9313                                        return Err(MongrelError::Conflict(format!(
9314                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
9315                                            fk.name
9316                                        )));
9317                                    }
9318                                }
9319                            }
9320                        }
9321                    }
9322                }
9323                Staged::Truncate => {
9324                    // Truncate is RESTRICT-only: reject if any child references
9325                    // this table (any FK action), since cascade-truncate is
9326                    // unsupported.
9327                    for (child_id, child_name, child_schema) in &live {
9328                        for fk in &child_schema.constraints.foreign_keys {
9329                            if fk.ref_table != tname {
9330                                continue;
9331                            }
9332                            let child_rows = load_rows(*child_id)?;
9333                            if child_rows
9334                                .iter()
9335                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
9336                            {
9337                                return Err(MongrelError::Conflict(format!(
9338                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
9339                                    fk.name
9340                                )));
9341                            }
9342                        }
9343                    }
9344                }
9345            }
9346        }
9347        Ok(())
9348    }
9349
9350    fn validate_write_permissions(
9351        &self,
9352        staging: &[(u64, crate::txn::Staged)],
9353        principal: Option<&crate::auth::Principal>,
9354        control: Option<&crate::ExecutionControl>,
9355    ) -> Result<()> {
9356        commit_prepare_checkpoint(control, 0)?;
9357        if principal.is_none() && !self.auth_state.require_auth() {
9358            return Ok(());
9359        }
9360        let principal = principal.ok_or(MongrelError::AuthRequired)?;
9361        let needs = summarize_write_permissions(staging);
9362        let catalog = self.catalog.read();
9363
9364        if needs.values().any(|need| need.truncate) {
9365            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
9366        }
9367        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
9368            commit_prepare_checkpoint(control, need_index)?;
9369            let entry = catalog
9370                .tables
9371                .iter()
9372                .find(|entry| {
9373                    entry.table_id == table_id
9374                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
9375                })
9376                .ok_or_else(|| {
9377                    MongrelError::NotFound(format!(
9378                        "live table {table_id} not found during write validation"
9379                    ))
9380                })?;
9381            if matches!(entry.state, TableState::Building { .. }) {
9382                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
9383                continue;
9384            }
9385            if need.insert {
9386                Self::require_columns_for_principal(
9387                    &entry.name,
9388                    &entry.schema,
9389                    crate::auth::ColumnOperation::Insert,
9390                    &need.insert_columns,
9391                    principal,
9392                )?;
9393            }
9394            if need.update {
9395                Self::require_columns_for_principal(
9396                    &entry.name,
9397                    &entry.schema,
9398                    crate::auth::ColumnOperation::Update,
9399                    &need.update_columns,
9400                    principal,
9401                )?;
9402            }
9403            if need.delete {
9404                self.require_for(
9405                    Some(principal),
9406                    &crate::auth::Permission::Delete {
9407                        table: entry.name.clone(),
9408                    },
9409                )?;
9410            }
9411        }
9412        Ok(())
9413    }
9414
9415    fn validate_security_writes(
9416        &self,
9417        staging: &[(u64, crate::txn::Staged)],
9418        read_epoch: Epoch,
9419        explicit_principal: Option<&crate::auth::Principal>,
9420        control: Option<&crate::ExecutionControl>,
9421    ) -> Result<()> {
9422        commit_prepare_checkpoint(control, 0)?;
9423        use crate::security::PolicyCommand;
9424        use crate::txn::Staged;
9425
9426        let catalog = self.catalog.read();
9427        if catalog.security.rls_tables.is_empty() {
9428            return Ok(());
9429        }
9430        let security = catalog.security.clone();
9431        let table_names = catalog
9432            .tables
9433            .iter()
9434            .filter(|entry| matches!(entry.state, TableState::Live))
9435            .map(|entry| (entry.table_id, entry.name.clone()))
9436            .collect::<HashMap<_, _>>();
9437        drop(catalog);
9438        if !staging.iter().any(|(table_id, _)| {
9439            table_names
9440                .get(table_id)
9441                .is_some_and(|table| security.rls_enabled(table))
9442        }) {
9443            return Ok(());
9444        }
9445        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
9446
9447        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
9448            commit_prepare_checkpoint(control, operation_index)?;
9449            let Some(table) = table_names.get(table_id) else {
9450                continue;
9451            };
9452            if !security.rls_enabled(table) || principal.is_admin {
9453                continue;
9454            }
9455            let denied = |command| MongrelError::PermissionDenied {
9456                required: match command {
9457                    PolicyCommand::Insert => crate::auth::Permission::Insert {
9458                        table: table.clone(),
9459                    },
9460                    PolicyCommand::Update => crate::auth::Permission::Update {
9461                        table: table.clone(),
9462                    },
9463                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
9464                        crate::auth::Permission::Delete {
9465                            table: table.clone(),
9466                        }
9467                    }
9468                },
9469                principal: principal.username.clone(),
9470            };
9471            match operation {
9472                Staged::Put(cells) => {
9473                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
9474                    row.columns.extend(cells.iter().cloned());
9475                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
9476                        return Err(denied(PolicyCommand::Insert));
9477                    }
9478                }
9479                Staged::Update {
9480                    row_id,
9481                    new_row: cells,
9482                    ..
9483                } => {
9484                    let old = self
9485                        .table_by_id(*table_id)?
9486                        .lock()
9487                        .get(*row_id, Snapshot::at(read_epoch))
9488                        .ok_or_else(|| {
9489                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9490                        })?;
9491                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
9492                        return Err(denied(PolicyCommand::Update));
9493                    }
9494                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
9495                    new.columns.extend(cells.iter().cloned());
9496                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
9497                        return Err(denied(PolicyCommand::Update));
9498                    }
9499                }
9500                Staged::Delete(row_id) => {
9501                    let old = self
9502                        .table_by_id(*table_id)?
9503                        .lock()
9504                        .get(*row_id, Snapshot::at(read_epoch))
9505                        .ok_or_else(|| {
9506                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9507                        })?;
9508                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
9509                        return Err(denied(PolicyCommand::Delete));
9510                    }
9511                }
9512                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
9513            }
9514        }
9515        Ok(())
9516    }
9517
9518    /// Seal a transaction (spec §9.3):
9519    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
9520    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
9521    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
9522    ///    group-sync, record conflict keys.
9523    /// 3. Publish — apply to tables, advance visible in-order.
9524    #[allow(clippy::too_many_arguments)]
9525    pub(crate) fn commit_transaction_with_external_states(
9526        &self,
9527        txn_id: u64,
9528        read_epoch: Epoch,
9529        staging: Vec<(u64, crate::txn::Staged)>,
9530        external_states: Vec<(String, Vec<u8>)>,
9531        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9532        security_principal: Option<crate::auth::Principal>,
9533        principal_catalog_bound: bool,
9534        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9535    ) -> Result<(Epoch, Vec<RowId>)> {
9536        self.commit_transaction_with_external_states_inner(
9537            txn_id,
9538            read_epoch,
9539            staging,
9540            external_states,
9541            materialized_view_updates,
9542            security_principal,
9543            principal_catalog_bound,
9544            external_trigger_bridge,
9545            None,
9546            None,
9547        )
9548    }
9549
9550    #[allow(clippy::too_many_arguments)]
9551    pub(crate) fn commit_transaction_with_external_states_controlled(
9552        &self,
9553        txn_id: u64,
9554        read_epoch: Epoch,
9555        staging: Vec<(u64, crate::txn::Staged)>,
9556        external_states: Vec<(String, Vec<u8>)>,
9557        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9558        security_principal: Option<crate::auth::Principal>,
9559        principal_catalog_bound: bool,
9560        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9561        control: &crate::ExecutionControl,
9562        before_commit: &mut dyn FnMut() -> Result<()>,
9563    ) -> Result<(Epoch, Vec<RowId>)> {
9564        self.commit_transaction_with_external_states_inner(
9565            txn_id,
9566            read_epoch,
9567            staging,
9568            external_states,
9569            materialized_view_updates,
9570            security_principal,
9571            principal_catalog_bound,
9572            external_trigger_bridge,
9573            Some(control),
9574            Some(before_commit),
9575        )
9576    }
9577
9578    #[allow(clippy::too_many_arguments)]
9579    fn commit_transaction_with_external_states_inner(
9580        &self,
9581        txn_id: u64,
9582        read_epoch: Epoch,
9583        mut staging: Vec<(u64, crate::txn::Staged)>,
9584        external_states: Vec<(String, Vec<u8>)>,
9585        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9586        mut security_principal: Option<crate::auth::Principal>,
9587        principal_catalog_bound: bool,
9588        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9589        control: Option<&crate::ExecutionControl>,
9590        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
9591    ) -> Result<(Epoch, Vec<RowId>)> {
9592        use crate::memtable::Row;
9593        use crate::txn::{Staged, StagedOp, WriteKey};
9594        use crate::wal::Op;
9595        use std::collections::hash_map::DefaultHasher;
9596        use std::hash::{Hash, Hasher};
9597        use std::sync::atomic::Ordering;
9598
9599        if txn_id == crate::wal::SYSTEM_TXN_ID {
9600            return Err(MongrelError::Full(
9601                "per-open transaction id namespace exhausted; reopen the database".into(),
9602            ));
9603        }
9604        if self.read_only {
9605            return Err(MongrelError::ReadOnlyReplica);
9606        }
9607        commit_prepare_checkpoint(control, 0)?;
9608        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
9609        self.refresh_security_catalog_if_stale(observed_security_version)?;
9610        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
9611        if self.auth_state.require_auth() && security_principal.is_none() {
9612            return Err(MongrelError::AuthRequired);
9613        }
9614        {
9615            let catalog = self.catalog.read();
9616            if catalog.require_auth
9617                || principal_catalog_bound
9618                || security_principal
9619                    .as_ref()
9620                    .is_some_and(|principal| principal.user_id != 0)
9621            {
9622                let principal = security_principal
9623                    .as_ref()
9624                    .ok_or(MongrelError::AuthRequired)?;
9625                security_principal =
9626                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
9627                if security_principal.is_none() {
9628                    return Err(MongrelError::AuthRequired);
9629                }
9630            }
9631        }
9632        let _replication_guard = self.replication_barrier.read();
9633        if self.poisoned.load(Ordering::Relaxed) {
9634            return Err(MongrelError::Other(
9635                "database poisoned by fsync error".into(),
9636            ));
9637        }
9638        let mut external_states = dedup_external_states(external_states);
9639        if !external_states.is_empty() {
9640            let cat = self.catalog.read();
9641            for (name, _) in &external_states {
9642                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
9643                    return Err(MongrelError::NotFound(format!(
9644                        "external table {name:?} not found"
9645                    )));
9646                }
9647            }
9648        }
9649        let prepared_materialized_views = {
9650            let mut deduplicated = HashMap::new();
9651            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
9652            {
9653                commit_prepare_checkpoint(control, definition_index)?;
9654                if definition.name.is_empty() || definition.query.trim().is_empty() {
9655                    return Err(MongrelError::InvalidArgument(
9656                        "materialized view name and query must not be empty".into(),
9657                    ));
9658                }
9659                deduplicated.insert(definition.name.clone(), definition);
9660            }
9661            let catalog = self.catalog.read();
9662            let mut prepared = Vec::with_capacity(deduplicated.len());
9663            for (definition_index, definition) in deduplicated.into_values().enumerate() {
9664                commit_prepare_checkpoint(control, definition_index)?;
9665                let table_id = catalog
9666                    .live(&definition.name)
9667                    .ok_or_else(|| {
9668                        MongrelError::NotFound(format!(
9669                            "materialized view table {:?} not found",
9670                            definition.name
9671                        ))
9672                    })?
9673                    .table_id;
9674                prepared.push((table_id, definition));
9675            }
9676            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
9677            prepared
9678        };
9679
9680        // ── 1. Prepare: fill generated values, expand triggers, validate, then
9681        // derive write keys from the final atomic write set.
9682        self.fill_auto_increment_for_staging(&mut staging, control)?;
9683        self.expand_table_triggers(
9684            &mut staging,
9685            read_epoch,
9686            external_trigger_bridge,
9687            &mut external_states,
9688            control,
9689        )?;
9690        self.fill_auto_increment_for_staging(&mut staging, control)?;
9691        external_states = dedup_external_states(external_states);
9692        let expected_external_generations = {
9693            let catalog = self.catalog.read();
9694            let mut generations = HashMap::with_capacity(external_states.len());
9695            for (name, _) in &external_states {
9696                let entry = catalog
9697                    .external_tables
9698                    .iter()
9699                    .find(|entry| entry.name == *name)
9700                    .ok_or_else(|| {
9701                        MongrelError::NotFound(format!("external table {name:?} not found"))
9702                    })?;
9703                generations.insert(name.clone(), entry.created_epoch);
9704            }
9705            generations
9706        };
9707
9708        // Validate declarative constraints (unique / FK / check) under the read
9709        // snapshot, outside the WAL mutex. Trigger-produced writes are included
9710        // here, so the batch either satisfies every declared constraint or is
9711        // rejected atomically.
9712        self.validate_constraints(&mut staging, read_epoch, control)?;
9713        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
9714        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
9715        let mut normalized = Vec::with_capacity(staging.len() * 2);
9716        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
9717            commit_prepare_checkpoint(control, staged_index)?;
9718            match op {
9719                crate::txn::Staged::Update {
9720                    row_id,
9721                    new_row: cells,
9722                    ..
9723                } => {
9724                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
9725                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
9726                }
9727                op => normalized.push((table_id, op)),
9728            }
9729        }
9730        staging = normalized;
9731        let has_changes = !staging.is_empty()
9732            || !external_states.is_empty()
9733            || !prepared_materialized_views.is_empty();
9734        let truncated_tables: HashSet<u64> = staging
9735            .iter()
9736            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
9737            .collect();
9738
9739        let write_keys = {
9740            let cat = self.catalog.read();
9741            let mut keys: Vec<WriteKey> = Vec::new();
9742            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9743                commit_prepare_checkpoint(control, staged_index)?;
9744                match staged {
9745                    Staged::Put(cells) => {
9746                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
9747                            for col in &entry.schema.columns {
9748                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
9749                                    if let Some((_, val)) =
9750                                        cells.iter().find(|(id, _)| *id == col.id)
9751                                    {
9752                                        let mut h = DefaultHasher::new();
9753                                        val.encode_key().hash(&mut h);
9754                                        keys.push(WriteKey::Unique {
9755                                            table_id: *table_id,
9756                                            index_id: 0,
9757                                            key_hash: h.finish(),
9758                                        });
9759                                    }
9760                                }
9761                            }
9762                            // Declared non-PK unique constraints register a
9763                            // `WriteKey::Unique` (namespace-separated from the
9764                            // PK's index_id==0 by setting the high bit) so two
9765                            // concurrent transactions inserting the same key
9766                            // cannot both commit. Rows with any NULL constrained
9767                            // column are skipped (SQL semantics).
9768                            for uc in &entry.schema.constraints.uniques {
9769                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
9770                                    &uc.columns,
9771                                    &cells.iter().cloned().collect(),
9772                                ) {
9773                                    let mut h = DefaultHasher::new();
9774                                    key_bytes.hash(&mut h);
9775                                    keys.push(WriteKey::Unique {
9776                                        table_id: *table_id,
9777                                        index_id: uc.id | 0x8000,
9778                                        key_hash: h.finish(),
9779                                    });
9780                                }
9781                            }
9782                        }
9783                    }
9784                    Staged::Delete(rid) => keys.push(WriteKey::Row {
9785                        table_id: *table_id,
9786                        row_id: rid.0,
9787                    }),
9788                    Staged::Truncate => keys.push(WriteKey::Table {
9789                        table_id: *table_id,
9790                    }),
9791                    Staged::Update { .. } => {
9792                        return Err(MongrelError::Other(
9793                            "transaction contains an unnormalized update during preparation".into(),
9794                        ));
9795                    }
9796                }
9797            }
9798            for (external_index, (name, _)) in external_states.iter().enumerate() {
9799                commit_prepare_checkpoint(control, external_index)?;
9800                let mut h = DefaultHasher::new();
9801                name.hash(&mut h);
9802                keys.push(WriteKey::Unique {
9803                    table_id: EXTERNAL_TABLE_ID,
9804                    index_id: 0,
9805                    key_hash: h.finish(),
9806                });
9807            }
9808            keys
9809        };
9810
9811        // Opportunistic pruning.
9812        let min_active = self.active_txns.min_read_epoch();
9813        if min_active < u64::MAX {
9814            self.conflicts.prune_below(Epoch(min_active));
9815        }
9816
9817        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
9818        // §8.5, review fix #17). Snapshot the conflict-index version so the
9819        // sequencer only re-checks if new commits arrived in the interim.
9820        if self.conflicts.conflicts(&write_keys, read_epoch) {
9821            return Err(MongrelError::Conflict(
9822                "write-write conflict (pre-validate, first-committer-wins)".into(),
9823            ));
9824        }
9825        let pre_validate_version = self.conflicts.version();
9826
9827        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
9828        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
9829        // streamed as Put records; they are linked at publish time.
9830        let mut spilled: Vec<SpilledRun> = Vec::new();
9831        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
9832        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
9833        // as the spill runs are live (registered on first spill, dropped at the
9834        // end of this function on commit/abort/error).
9835        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
9836        {
9837            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
9838            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
9839            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9840                commit_prepare_checkpoint(control, staged_index)?;
9841                if let Staged::Put(cells) = staged {
9842                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
9843                        bytes.saturating_add(value.estimated_bytes())
9844                    });
9845                    let table_bytes = table_bytes.entry(*table_id).or_default();
9846                    *table_bytes = table_bytes.saturating_add(bytes);
9847                    put_indexes.entry(*table_id).or_default().push(staged_index);
9848                }
9849            }
9850            let tables = self.tables.read();
9851            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
9852                commit_prepare_checkpoint(control, table_index)?;
9853                if bytes
9854                    <= self
9855                        .spill_threshold
9856                        .load(std::sync::atomic::Ordering::Relaxed)
9857                {
9858                    continue;
9859                }
9860                let Some(handle) = tables.get(&table_id) else {
9861                    continue;
9862                };
9863                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
9864                let mut t = handle.lock();
9865                let tdir = t.table_dir().to_path_buf();
9866                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
9867                std::fs::create_dir_all(&txn_dir)?;
9868                let run_id = t.alloc_run_id()? as u128;
9869                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
9870                let final_path = t.run_path(run_id as u64);
9871
9872                let mut rows: Vec<Row> = Vec::new();
9873                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
9874                    commit_prepare_checkpoint(control, put_index)?;
9875                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
9876                        return Err(MongrelError::Other(
9877                            "transaction put index no longer references a put".into(),
9878                        ));
9879                    };
9880                    t.validate_cells_not_null(cells)?;
9881                    let row_id = t.alloc_row_id()?;
9882                    let mut row = Row::new(row_id, Epoch(0));
9883                    row.columns.extend(std::mem::take(cells));
9884                    rows.push(row);
9885                }
9886                let schema = t.schema_ref().clone();
9887                let kek = t.kek_ref().cloned();
9888                let specs = t.indexable_column_specs();
9889                drop(t);
9890
9891                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
9892                    .uniform_epoch(true);
9893                if let Some(ref kek) = kek {
9894                    writer = writer.with_encryption(kek.as_ref(), specs);
9895                }
9896                commit_prepare_checkpoint(control, 0)?;
9897                let header = writer.write(&pending_path, &rows)?;
9898                commit_prepare_checkpoint(control, 0)?;
9899                let row_count = header.row_count;
9900                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
9901                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
9902
9903                spilled.push(SpilledRun {
9904                    table_id,
9905                    run_id,
9906                    pending_path,
9907                    final_path,
9908                    rows,
9909                    row_count,
9910                    min_rid,
9911                    max_rid,
9912                    content_hash: header.content_hash,
9913                });
9914                spilled_tables.insert(table_id);
9915            }
9916        }
9917
9918        // Test seam: let a test race `gc()` against this in-flight spill.
9919        if spill_guard.is_some() {
9920            if let Some(hook) = self.spill_hook.lock().as_ref() {
9921                hook();
9922            }
9923        }
9924
9925        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
9926        // Allocating row ids + building the rows here (lock order: table handle →
9927        // nothing) means the sequencer never locks a table handle while holding
9928        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
9929        // the table handle THEN the shared WAL; if the sequencer did the reverse
9930        // (WAL then handle) the two paths would deadlock (review fix: B1).
9931        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
9932        // Row ids are allocated here, before the sequencer's delta conflict
9933        // re-check, so a losing txn leaks the ids it reserved — harmless, the
9934        // u64 row-id space is monotonic and gaps are expected (spills do the same).
9935        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9936            .take(staging.len())
9937            .collect();
9938        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9939            .take(staging.len())
9940            .collect();
9941        {
9942            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
9943            for (index, (table_id, staged)) in staging.iter().enumerate() {
9944                commit_prepare_checkpoint(control, index)?;
9945                if matches!(staged, Staged::Delete(_))
9946                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
9947                {
9948                    indexes_by_table.entry(*table_id).or_default().push(index);
9949                }
9950            }
9951            let tables = self.tables.read();
9952            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
9953                commit_prepare_checkpoint(control, table_index)?;
9954                let handle = tables.get(&table_id).ok_or_else(|| {
9955                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9956                })?;
9957                #[cfg(test)]
9958                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
9959                let mut t = handle.lock();
9960                for (prepare_index, index) in indexes.into_iter().enumerate() {
9961                    commit_prepare_checkpoint(control, prepare_index)?;
9962                    match &staging[index].1 {
9963                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
9964                            t.validate_cells_not_null(cells)?;
9965                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
9966                            for (column, value) in cells {
9967                                row.columns.insert(*column, value.clone());
9968                            }
9969                            prebuilt[index] = Some(row);
9970                        }
9971                        Staged::Delete(row_id) => {
9972                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
9973                        }
9974                        Staged::Put(_) | Staged::Truncate => {}
9975                        Staged::Update { .. } => {
9976                            return Err(MongrelError::Other(
9977                                "transaction contains an unnormalized update during row preparation"
9978                                    .into(),
9979                            ));
9980                        }
9981                    }
9982                }
9983            }
9984        }
9985
9986        // Finish every fallible index read before the commit marker can become
9987        // durable. Post-durable row/run metadata application is then entirely
9988        // in-memory and cannot stop halfway through a multi-table publish.
9989        let prepared_table_handles = {
9990            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
9991            let put_table_ids: HashSet<u64> = staging
9992                .iter()
9993                .filter_map(|(table_id, staged)| {
9994                    matches!(staged, Staged::Put(_)).then_some(*table_id)
9995                })
9996                .collect();
9997            let tables = self.tables.read();
9998            let mut handles = HashMap::with_capacity(table_ids.len());
9999            for (table_index, table_id) in table_ids.into_iter().enumerate() {
10000                commit_prepare_checkpoint(control, table_index)?;
10001                let handle = tables.get(&table_id).ok_or_else(|| {
10002                    MongrelError::NotFound(format!("table {table_id} not mounted"))
10003                })?;
10004                if put_table_ids.contains(&table_id) {
10005                    match control {
10006                        Some(control) => {
10007                            handle.lock().prepare_durable_publish_controlled(control)?
10008                        }
10009                        None => handle.lock().prepare_durable_publish()?,
10010                    }
10011                }
10012                handles.insert(table_id, handle.clone());
10013            }
10014            handles
10015        };
10016
10017        // Link large-transaction spill files before WAL durability. The guard
10018        // restores their pending names on every error before WAL append begins;
10019        // publication only attaches already-present files in memory.
10020        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
10021
10022        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
10023            .iter()
10024            .map(|run| {
10025                (
10026                    run.table_id,
10027                    run.rows.iter().map(|row| row.row_id).collect(),
10028                )
10029            })
10030            .collect();
10031        let committed_row_ids = staging
10032            .iter()
10033            .enumerate()
10034            .filter_map(|(index, (table_id, staged))| {
10035                if !matches!(staged, Staged::Put(_)) {
10036                    return None;
10037                }
10038                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
10039                    spilled_row_ids
10040                        .get_mut(table_id)
10041                        .and_then(VecDeque::pop_front)
10042                })
10043            })
10044            .collect();
10045
10046        let mut prepared_external = Vec::with_capacity(external_states.len());
10047        for (external_index, (name, state)) in external_states.iter().enumerate() {
10048            commit_prepare_checkpoint(control, external_index)?;
10049            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
10050            prepared_external.push((name.clone(), state.clone(), pending));
10051        }
10052
10053        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
10054        let added_runs: Vec<crate::wal::AddedRun> = spilled
10055            .iter()
10056            .map(|s| crate::wal::AddedRun {
10057                table_id: s.table_id,
10058                run_id: s.run_id,
10059                row_count: s.row_count,
10060                level: 0,
10061                min_row_id: s.min_rid,
10062                max_row_id: s.max_rid,
10063                content_hash: s.content_hash,
10064            })
10065            .collect();
10066        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
10067            hook();
10068        }
10069        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
10070        // Security mutations cannot overtake an authorized commit before its
10071        // commit marker is durable.
10072        let security_guard = self.security_coordinator.gate.read();
10073        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
10074            return Err(MongrelError::Conflict(
10075                "security policy changed during write".into(),
10076            ));
10077        }
10078        if spill_guard.is_some() {
10079            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
10080                hook();
10081            }
10082        }
10083        let commit_guard = self.commit_lock.lock();
10084        let catalog_generation_result = (|| {
10085            {
10086                let catalog = self.catalog.read();
10087                for table_id in prepared_table_handles.keys() {
10088                    let is_current = catalog.tables.iter().any(|entry| {
10089                        entry.table_id == *table_id
10090                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
10091                    });
10092                    if !is_current {
10093                        return Err(MongrelError::Conflict(format!(
10094                            "table {table_id} changed during transaction preparation"
10095                        )));
10096                    }
10097                }
10098                for (name, created_epoch) in &expected_external_generations {
10099                    let current = catalog
10100                        .external_tables
10101                        .iter()
10102                        .find(|entry| entry.name == *name)
10103                        .map(|entry| entry.created_epoch);
10104                    if current != Some(*created_epoch) {
10105                        return Err(MongrelError::Conflict(format!(
10106                            "external table {name:?} changed during transaction preparation"
10107                        )));
10108                    }
10109                }
10110                for (table_id, definition) in &prepared_materialized_views {
10111                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
10112                    if current != Some(*table_id) {
10113                        return Err(MongrelError::Conflict(format!(
10114                            "materialized view {:?} changed during transaction preparation",
10115                            definition.name
10116                        )));
10117                    }
10118                }
10119                if trigger_catalog_binding(&catalog) != trigger_binding {
10120                    return Err(MongrelError::Conflict(
10121                        "trigger or referenced table generation changed during transaction preparation"
10122                            .into(),
10123                    ));
10124                }
10125            }
10126            let tables = self.tables.read();
10127            for (table_id, prepared) in &prepared_table_handles {
10128                if !tables
10129                    .get(table_id)
10130                    .is_some_and(|current| current.ptr_eq(prepared))
10131                {
10132                    return Err(MongrelError::Conflict(format!(
10133                        "table {table_id} mount changed during transaction preparation"
10134                    )));
10135                }
10136            }
10137            Ok(())
10138        })();
10139        if let Err(error) = catalog_generation_result {
10140            drop(commit_guard);
10141            for (_, _, pending) in &prepared_external {
10142                let _ = std::fs::remove_file(pending);
10143            }
10144            return Err(error);
10145        }
10146        // The commit lock keeps the next epoch stable while logical spill
10147        // records are serialized. Build them before taking the shared WAL
10148        // lock, and cap their aggregate memory/WAL footprint.
10149        let new_epoch = self.epoch.assigned().next();
10150        let mut spilled_wal_bytes = 0;
10151        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
10152        let spill_prepare = (|| {
10153            for run in &mut spilled {
10154                for row in &mut run.rows {
10155                    row.committed_epoch = new_epoch;
10156                }
10157                for rows in encode_spilled_row_chunks(
10158                    &run.rows,
10159                    &mut spilled_wal_bytes,
10160                    SPILLED_WAL_TOTAL_MAX_BYTES,
10161                    control,
10162                )? {
10163                    spilled_wal_records.push((
10164                        run.table_id,
10165                        Op::SpilledRows {
10166                            table_id: run.table_id,
10167                            rows,
10168                        },
10169                    ));
10170                }
10171            }
10172            Result::<()>::Ok(())
10173        })();
10174        if let Err(error) = spill_prepare {
10175            for (_, _, pending) in &prepared_external {
10176                let _ = std::fs::remove_file(pending);
10177            }
10178            return Err(error);
10179        }
10180        let (new_epoch, mut _epoch_guard, applies, committed_materialized_views, commit_seq) = {
10181            let mut wal = self.shared_wal.lock();
10182
10183            // Re-check only if the conflict index advanced since pre-validation
10184            // (bounded delta — spec §8.5, review fix #17). If the version is
10185            // unchanged, the pre-check result is still valid and the sequencer
10186            // does O(1) work regardless of write-set size.
10187            if self.conflicts.version() != pre_validate_version
10188                && self.conflicts.conflicts(&write_keys, read_epoch)
10189            {
10190                // Abort: this txn assigned no epoch yet. The prepared-run guard
10191                // restores final run names to their pending paths on return.
10192                drop(wal);
10193                for (_, _, pending) in &prepared_external {
10194                    let _ = std::fs::remove_file(pending);
10195                }
10196                return Err(MongrelError::Conflict(
10197                    "write-write conflict (sequencer delta re-check)".into(),
10198                ));
10199            }
10200
10201            if let Some(control) = control {
10202                if let Err(error) = control.checkpoint() {
10203                    drop(wal);
10204                    for (_, _, pending) in &prepared_external {
10205                        let _ = std::fs::remove_file(pending);
10206                    }
10207                    return Err(error);
10208                }
10209            }
10210            let mut applies = Vec::<TableApplyBatch>::new();
10211            let mut apply_indexes = HashMap::<u64, usize>::new();
10212            let mut committed_materialized_views = Vec::new();
10213            let mut wal_records = spilled_wal_records;
10214
10215            let mut index = 0;
10216            while index < staging.len() {
10217                let table_id = staging[index].0;
10218                let handle = prepared_table_handles
10219                    .get(&table_id)
10220                    .cloned()
10221                    .ok_or_else(|| {
10222                        MongrelError::NotFound(format!("table {table_id} not prepared"))
10223                    })?;
10224                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
10225                    let index = applies.len();
10226                    applies.push(TableApplyBatch {
10227                        table_id,
10228                        handle,
10229                        ops: Vec::new(),
10230                    });
10231                    index
10232                });
10233
10234                // Skip puts for tables that were spilled — their data is in a
10235                // pending run, not in streamed Put records.
10236                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
10237                {
10238                    index += 1;
10239                    continue;
10240                }
10241
10242                match &staging[index].1 {
10243                    Staged::Put(_) => {
10244                        let mut rows = Vec::new();
10245                        while index < staging.len()
10246                            && staging[index].0 == table_id
10247                            && matches!(&staging[index].1, Staged::Put(_))
10248                        {
10249                            let mut row = prebuilt[index].take().ok_or_else(|| {
10250                                MongrelError::Other(
10251                                    "transaction prepare lost a prebuilt put row".into(),
10252                                )
10253                            })?;
10254                            row.committed_epoch = new_epoch;
10255                            rows.push(row);
10256                            index += 1;
10257                        }
10258                        let payload = bincode::serialize(&rows)
10259                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
10260                        wal_records.push((
10261                            table_id,
10262                            Op::Put {
10263                                table_id,
10264                                rows: payload,
10265                            },
10266                        ));
10267                        applies[batch_index].ops.push(StagedOp::Put(rows));
10268                    }
10269                    Staged::Delete(_) => {
10270                        let mut row_ids = Vec::new();
10271                        while index < staging.len()
10272                            && staging[index].0 == table_id
10273                            && matches!(&staging[index].1, Staged::Delete(_))
10274                        {
10275                            let Staged::Delete(row_id) = &staging[index].1 else {
10276                                return Err(MongrelError::Other(
10277                                    "transaction delete batch changed during WAL preparation"
10278                                        .into(),
10279                                ));
10280                            };
10281                            if let Some(before) = &delete_images[index] {
10282                                wal_records.push((
10283                                    table_id,
10284                                    Op::BeforeImage {
10285                                        table_id,
10286                                        row_id: *row_id,
10287                                        row: bincode::serialize(before).map_err(|error| {
10288                                            MongrelError::Other(format!(
10289                                                "before-image serialize: {error}"
10290                                            ))
10291                                        })?,
10292                                    },
10293                                ));
10294                            }
10295                            row_ids.push(*row_id);
10296                            index += 1;
10297                        }
10298                        wal_records.push((
10299                            table_id,
10300                            Op::Delete {
10301                                table_id,
10302                                row_ids: row_ids.clone(),
10303                            },
10304                        ));
10305                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
10306                    }
10307                    Staged::Truncate => {
10308                        wal_records.push((table_id, Op::TruncateTable { table_id }));
10309                        applies[batch_index].ops.push(StagedOp::Truncate);
10310                        index += 1;
10311                    }
10312                    Staged::Update { .. } => {
10313                        return Err(MongrelError::Other(
10314                            "transaction contains an unnormalized update at the sequencer".into(),
10315                        ));
10316                    }
10317                }
10318            }
10319
10320            for (name, state, _) in &prepared_external {
10321                wal_records.push((
10322                    EXTERNAL_TABLE_ID,
10323                    Op::ExternalTableState {
10324                        name: name.clone(),
10325                        state: state.clone(),
10326                    },
10327                ));
10328            }
10329
10330            for (table_id, definition) in &prepared_materialized_views {
10331                let mut definition = definition.clone();
10332                definition.last_refresh_epoch = new_epoch.0;
10333                wal_records.push((
10334                    *table_id,
10335                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
10336                        name: definition.name.clone(),
10337                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
10338                    }),
10339                ));
10340                committed_materialized_views.push(definition);
10341            }
10342            if !committed_materialized_views.is_empty() {
10343                let mut next_catalog = self.catalog.read().clone();
10344                for definition in &committed_materialized_views {
10345                    if let Some(existing) = next_catalog
10346                        .materialized_views
10347                        .iter_mut()
10348                        .find(|existing| existing.name == definition.name)
10349                    {
10350                        *existing = definition.clone();
10351                    } else {
10352                        next_catalog.materialized_views.push(definition.clone());
10353                    }
10354                }
10355                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10356                wal_records.push((
10357                    WAL_TABLE_ID,
10358                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
10359                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
10360                    }),
10361                ));
10362            }
10363
10364            if let Some(control) = control {
10365                if let Err(error) = control.checkpoint() {
10366                    drop(wal);
10367                    for (_, _, pending) in &prepared_external {
10368                        let _ = std::fs::remove_file(pending);
10369                    }
10370                    return Err(error);
10371                }
10372            }
10373            if let Some(before_commit) = before_commit.as_mut() {
10374                if let Err(error) = before_commit() {
10375                    drop(wal);
10376                    for (_, _, pending) in &prepared_external {
10377                        let _ = std::fs::remove_file(pending);
10378                    }
10379                    return Err(error);
10380                }
10381            }
10382
10383            let assigned_epoch = self.epoch.bump_assigned();
10384            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
10385            if assigned_epoch != new_epoch {
10386                for (_, _, pending) in &prepared_external {
10387                    let _ = std::fs::remove_file(pending);
10388                }
10389                return Err(MongrelError::Conflict(
10390                    "commit epoch changed while sequencer lock was held".into(),
10391                ));
10392            }
10393
10394            // From this point the outcome can become ambiguous. Keep prepared
10395            // spill files at the final names referenced by a possibly durable
10396            // commit marker; orphan cleanup is safe when the append did fail.
10397            prepared_run_links.disarm();
10398
10399            let append: Result<u64> = (|| {
10400                for (table_id, op) in wal_records {
10401                    wal.append(txn_id, table_id, op)?;
10402                }
10403                wal.append_commit(txn_id, new_epoch, &added_runs)
10404            })();
10405            let commit_seq =
10406                append.map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
10407
10408            // Record the conflict + assign the epoch under the WAL lock so commit
10409            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
10410            // moves out of this critical section to the group-commit coordinator
10411            // so concurrent committers share a single leader fsync.
10412            self.conflicts.record(&write_keys, new_epoch);
10413            (
10414                new_epoch,
10415                _epoch_guard,
10416                applies,
10417                committed_materialized_views,
10418                commit_seq,
10419            )
10420        };
10421        drop(commit_guard);
10422
10423        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
10424        self.await_durable_commit(commit_seq, new_epoch)?;
10425        drop(security_guard);
10426
10427        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
10428        let publish_result: Result<()> = {
10429            let mut first_error = None;
10430            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
10431            for run in &spilled {
10432                spilled_by_table.entry(run.table_id).or_default().push(run);
10433            }
10434            let mut modified_tables = Vec::with_capacity(applies.len());
10435            // Apply every table completely before any fallible manifest write.
10436            // The visible epoch remains unchanged until all tables are coherent.
10437            for batch in applies {
10438                #[cfg(test)]
10439                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10440                let mut t = batch.handle.lock();
10441                for op in batch.ops {
10442                    match op {
10443                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
10444                        StagedOp::Delete(row_ids) => {
10445                            for row_id in row_ids {
10446                                t.apply_delete(row_id, new_epoch);
10447                            }
10448                        }
10449                        StagedOp::Truncate => t.apply_truncate(new_epoch),
10450                    }
10451                }
10452                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
10453                    for run in runs {
10454                        t.link_run(crate::manifest::RunRef {
10455                            run_id: run.run_id,
10456                            level: 0,
10457                            epoch_created: new_epoch.0,
10458                            row_count: run.row_count,
10459                        });
10460                        t.apply_run_metadata_prepared(&run.rows)?;
10461                        if truncated_tables.contains(&batch.table_id) {
10462                            // TRUNCATE + spilled puts fully describe this table at
10463                            // the commit epoch. Endorse the epoch so clean-reopen
10464                            // recovery does not replay the truncate over the
10465                            // already-linked replacement run.
10466                            t.set_flushed_epoch(new_epoch);
10467                        }
10468                    }
10469                }
10470                t.invalidate_pending_cache();
10471                drop(t);
10472                modified_tables.push(batch.handle);
10473            }
10474
10475            // Checkpoint only after every live table carries the durable state.
10476            // Continue after one checkpoint failure so runtime publication stays
10477            // all-or-nothing; WAL recovery repairs failed files on reopen.
10478            for handle in modified_tables {
10479                #[cfg(test)]
10480                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
10481                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
10482                    first_error.get_or_insert(error);
10483                }
10484            }
10485            for (name, _, pending) in &prepared_external {
10486                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
10487                    first_error.get_or_insert(error);
10488                }
10489            }
10490            if !committed_materialized_views.is_empty() {
10491                let mut next_catalog = self.catalog.read().clone();
10492                for definition in committed_materialized_views {
10493                    if let Some(existing) = next_catalog
10494                        .materialized_views
10495                        .iter_mut()
10496                        .find(|existing| existing.name == definition.name)
10497                    {
10498                        *existing = definition;
10499                    } else {
10500                        next_catalog.materialized_views.push(definition);
10501                    }
10502                }
10503                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10504                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
10505                    first_error.get_or_insert(error);
10506                }
10507            }
10508            match first_error {
10509                Some(error) => Err(error),
10510                None => Ok(()),
10511            }
10512        };
10513
10514        if has_changes {
10515            let _ = self.change_wake.send(());
10516        }
10517        self.finish_durable_publish(new_epoch, &mut _epoch_guard, publish_result)?;
10518        Ok((new_epoch, committed_row_ids))
10519    }
10520
10521    /// Register a read snapshot at the current visible epoch and return it with
10522    /// a guard that retains it for GC until dropped.
10523    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
10524        let e = self.epoch.visible();
10525        let g = self.snapshots.register(e);
10526        (Snapshot::at(e), g)
10527    }
10528
10529    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
10530    /// retention.
10531    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
10532        let e = self.epoch.visible();
10533        let g = self.snapshots.register_owned(e);
10534        (Snapshot::at(e), g)
10535    }
10536
10537    /// Configure a rolling history window measured in prior commit epochs.
10538    /// The first enable starts at the current epoch because earlier versions
10539    /// may already have been compacted. Increasing the window likewise cannot
10540    /// recreate history that fell outside the previous guarantee.
10541    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
10542        let _guard = self.ddl_lock.lock();
10543        let current = self.epoch.visible();
10544        let (old_epochs, old_start) = self.snapshots.history_config();
10545        let earliest_already_guaranteed = if old_epochs == 0 {
10546            current
10547        } else {
10548            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
10549        };
10550        let start = if epochs == 0 {
10551            current
10552        } else {
10553            earliest_already_guaranteed
10554        };
10555        let published = std::cell::Cell::new(false);
10556        let result = write_history_retention(&self.root, epochs, start, || {
10557            self.snapshots.configure_history(epochs, start);
10558            published.set(true);
10559        });
10560        match result {
10561            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
10562                epoch: current.0,
10563                message: format!("history-retention publication was not durable: {error}"),
10564            }),
10565            result => result,
10566        }
10567    }
10568
10569    pub fn history_retention_epochs(&self) -> u64 {
10570        self.snapshots.history_config().0
10571    }
10572
10573    pub fn earliest_retained_epoch(&self) -> Epoch {
10574        let current = self.epoch.visible();
10575        self.snapshots.history_floor(current).unwrap_or(current)
10576    }
10577
10578    /// Pin a guaranteed historical epoch for the lifetime of the returned
10579    /// guard. Rejects future epochs and epochs outside the configured window.
10580    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
10581        let current = self.epoch.visible();
10582        if epoch > current {
10583            return Err(MongrelError::InvalidArgument(format!(
10584                "epoch {} is in the future; current epoch is {}",
10585                epoch.0, current.0
10586            )));
10587        }
10588        let earliest = self.earliest_retained_epoch();
10589        if epoch < earliest {
10590            return Err(MongrelError::InvalidArgument(format!(
10591                "epoch {} is no longer retained; earliest available epoch is {}",
10592                epoch.0, earliest.0
10593            )));
10594        }
10595        let guard = self.snapshots.register_owned(epoch);
10596        Ok((Snapshot::at(epoch), guard))
10597    }
10598
10599    /// Names of all live tables.
10600    pub fn table_names(&self) -> Vec<String> {
10601        self.catalog
10602            .read()
10603            .tables
10604            .iter()
10605            .filter(|t| matches!(t.state, TableState::Live))
10606            .map(|t| t.name.clone())
10607            .collect()
10608    }
10609
10610    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
10611    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
10612    /// reaped on the next open. Call this as the last action before a
10613    /// short-lived process (CLI, one-shot script) exits. The daemon does not
10614    /// need this — its background auto-compactor handles run management.
10615    pub fn close(&self) -> Result<()> {
10616        for name in self.table_names() {
10617            if let Ok(handle) = self.table(&name) {
10618                if let Err(e) = handle.lock().close() {
10619                    eprintln!("[close] flush failed for {name}: {e}");
10620                }
10621            }
10622        }
10623        Ok(())
10624    }
10625
10626    /// Compact every mounted table: merge all sorted runs into one clean run
10627    /// so query cost stays flat (single-run fast path) instead of growing
10628    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
10629    /// rows to reclaim. Each table
10630    /// is locked individually for its own compaction; snapshot retention is
10631    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
10632    pub fn compact(&self) -> Result<(usize, usize)> {
10633        self.require(&crate::auth::Permission::Ddl)?;
10634        let mut compacted = 0;
10635        let mut skipped = 0;
10636        for name in self.table_names() {
10637            let Ok(handle) = self.table(&name) else {
10638                continue;
10639            };
10640            {
10641                let mut t = handle.lock();
10642                let before = t.run_count();
10643                if before < 2 && !t.should_compact() {
10644                    skipped += 1;
10645                    continue;
10646                }
10647                match t.compact() {
10648                    Ok(()) => {
10649                        let after = t.run_count();
10650                        compacted += 1;
10651                        eprintln!("[compact] {name}: {before} -> {after} runs");
10652                    }
10653                    Err(e) => {
10654                        eprintln!("[compact] {name}: compaction failed: {e}");
10655                        skipped += 1;
10656                    }
10657                }
10658            }
10659        }
10660        Ok((compacted, skipped))
10661    }
10662
10663    /// Compact a single table by name. Returns `Ok(true)` if it was
10664    /// compacted, `Ok(false)` if skipped (< 2 runs).
10665    pub fn compact_table(&self, name: &str) -> Result<bool> {
10666        self.require(&crate::auth::Permission::Ddl)?;
10667        let handle = self.table(name)?;
10668        let mut t = handle.lock();
10669        let before = t.run_count();
10670        if before < 2 {
10671            return Ok(false);
10672        }
10673        t.compact()?;
10674        Ok(t.run_count() < before)
10675    }
10676
10677    /// Look up a live table by name.
10678    pub fn table(&self, name: &str) -> Result<TableHandle> {
10679        self.ensure_owner_process()?;
10680        let cat = self.catalog.read();
10681        let entry = cat
10682            .live(name)
10683            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10684        let id = entry.table_id;
10685        drop(cat);
10686        self.tables
10687            .read()
10688            .get(&id)
10689            .cloned()
10690            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
10691    }
10692
10693    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
10694    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
10695    pub fn has_ttl_tables(&self) -> bool {
10696        self.tables
10697            .read()
10698            .values()
10699            .any(|table| table.lock().ttl().is_some())
10700    }
10701
10702    /// Resolve a live table id → mounted handle (used by the constraint
10703    /// validation pass and other id-qualified internal paths).
10704    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
10705        self.tables
10706            .read()
10707            .get(&id)
10708            .cloned()
10709            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
10710    }
10711
10712    /// Create a new table. The DDL is first logged to the shared WAL
10713    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
10714    /// BEFORE the in-memory catalog and table map are mutated; the catalog
10715    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
10716    /// that sees a stale catalog still recovers the table by replaying the Ddl.
10717    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
10718        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10719            return Err(MongrelError::InvalidArgument(format!(
10720                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
10721            )));
10722        }
10723        self.create_table_with_state(name, schema, TableState::Live)
10724    }
10725
10726    /// Create a durable but non-queryable CTAS build table.
10727    #[doc(hidden)]
10728    pub fn create_building_table(
10729        &self,
10730        build_name: &str,
10731        intended_name: &str,
10732        query_id: &str,
10733        schema: Schema,
10734    ) -> Result<u64> {
10735        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10736            || intended_name.is_empty()
10737            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10738            || query_id.is_empty()
10739        {
10740            return Err(MongrelError::InvalidArgument(
10741                "invalid CTAS building-table identity".into(),
10742            ));
10743        }
10744        self.create_table_with_state(
10745            build_name,
10746            schema,
10747            TableState::Building {
10748                intended_name: intended_name.to_string(),
10749                query_id: query_id.to_string(),
10750                created_at_unix_nanos: current_unix_nanos(),
10751                replaces_table_id: None,
10752            },
10753        )
10754    }
10755
10756    /// Create a hidden schema-rebuild table while the intended target remains
10757    /// live. Publication later validates that the same target is still live.
10758    #[doc(hidden)]
10759    pub fn create_rebuilding_table(
10760        &self,
10761        build_name: &str,
10762        intended_name: &str,
10763        query_id: &str,
10764        schema: Schema,
10765    ) -> Result<u64> {
10766        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10767            || intended_name.is_empty()
10768            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10769            || query_id.is_empty()
10770        {
10771            return Err(MongrelError::InvalidArgument(
10772                "invalid rebuilding-table identity".into(),
10773            ));
10774        }
10775        let replaces_table_id = self
10776            .catalog
10777            .read()
10778            .live(intended_name)
10779            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
10780            .table_id;
10781        self.create_table_with_state(
10782            build_name,
10783            schema,
10784            TableState::Building {
10785                intended_name: intended_name.to_string(),
10786                query_id: query_id.to_string(),
10787                created_at_unix_nanos: current_unix_nanos(),
10788                replaces_table_id: Some(replaces_table_id),
10789            },
10790        )
10791    }
10792
10793    fn create_table_with_state(
10794        &self,
10795        name: &str,
10796        schema: Schema,
10797        state: TableState,
10798    ) -> Result<u64> {
10799        use crate::wal::DdlOp;
10800        use std::sync::atomic::Ordering;
10801
10802        self.require(&crate::auth::Permission::Ddl)?;
10803        if self.poisoned.load(Ordering::Relaxed) {
10804            return Err(MongrelError::Other(
10805                "database poisoned by fsync error".into(),
10806            ));
10807        }
10808
10809        let _g = self.ddl_lock.lock();
10810        let _security_write = self.security_write()?;
10811        self.require(&crate::auth::Permission::Ddl)?;
10812        {
10813            let cat = self.catalog.read();
10814            match &state {
10815                TableState::Live => {
10816                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
10817                        return Err(MongrelError::InvalidArgument(format!(
10818                            "table {name:?} already exists or is being built"
10819                        )));
10820                    }
10821                }
10822                TableState::Building {
10823                    intended_name,
10824                    replaces_table_id,
10825                    ..
10826                } => {
10827                    let target_matches = match replaces_table_id {
10828                        Some(table_id) => cat
10829                            .live(intended_name)
10830                            .is_some_and(|entry| entry.table_id == *table_id),
10831                        None => cat.live(intended_name).is_none(),
10832                    };
10833                    if !target_matches || cat.building_for(intended_name).is_some() {
10834                        return Err(MongrelError::InvalidArgument(format!(
10835                            "table {intended_name:?} changed or is already being built"
10836                        )));
10837                    }
10838                    if cat.building(name).is_some() {
10839                        return Err(MongrelError::InvalidArgument(format!(
10840                            "building table {name:?} already exists"
10841                        )));
10842                    }
10843                }
10844                TableState::Dropped { .. } => {
10845                    return Err(MongrelError::InvalidArgument(
10846                        "cannot create a dropped table".into(),
10847                    ));
10848                }
10849            }
10850        }
10851
10852        // Allocate id + epoch + txn id under the commit lock so the DDL commit
10853        // is serialized with data commits (in-order publish).
10854        let commit_lock = Arc::clone(&self.commit_lock);
10855        let _c = commit_lock.lock();
10856        let table_id = {
10857            let mut cat = self.catalog.write();
10858            let id = cat.next_table_id;
10859            cat.next_table_id = id
10860                .checked_add(1)
10861                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
10862            Result::<u64>::Ok(id)
10863        }?;
10864        let epoch = self.epoch.bump_assigned();
10865        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10866        let txn_id = self.alloc_txn_id()?;
10867
10868        // Stamp the schema_id with the unique table_id so every table in the
10869        // database has a distinct schema_id (caller-provided values are
10870        // ignored to prevent collisions).
10871        let mut schema = schema;
10872        schema.schema_id = table_id;
10873        // Defense in depth: reject an invalid schema BEFORE any durable
10874        // side-effect. `Table::create_in` re-validates, but by then the DDL has
10875        // already been appended to the shared WAL; a failing create_in would
10876        // leave a dangling entry that `recover_ddl_from_wal` replays without
10877        // re-validating, corrupting the catalog on reopen. Validating here
10878        // keeps the WAL free of schemas that can never be opened.
10879        schema.validate_auto_increment()?;
10880        schema.validate_defaults()?;
10881        schema.validate_ai()?;
10882        for index in &schema.indexes {
10883            index.validate_options()?;
10884        }
10885        for constraint in &schema.constraints.checks {
10886            constraint.expr.validate()?;
10887        }
10888
10889        // Build the complete mounted table before its DDL can become durable.
10890        // Any failure removes the unpublished directory and abandons the epoch.
10891        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
10892        let canonical_tdir = self.root.join(&table_relative);
10893        let table_root = Arc::new(
10894            self.durable_root
10895                .create_directory_all_pinned(&table_relative)?,
10896        );
10897        let tdir = table_root.io_path()?;
10898        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
10899        let ctx = SharedCtx {
10900            root_guard: Some(table_root),
10901            epoch: Arc::clone(&self.epoch),
10902            page_cache: Arc::clone(&self.page_cache),
10903            decoded_cache: Arc::clone(&self.decoded_cache),
10904            snapshots: Arc::clone(&self.snapshots),
10905            kek: self.kek.clone(),
10906            commit_lock: Arc::clone(&self.commit_lock),
10907            shared: Some(crate::engine::SharedWalCtx {
10908                wal: Arc::clone(&self.shared_wal),
10909                group: Arc::clone(&self.group),
10910                poisoned: Arc::clone(&self.poisoned),
10911                txn_ids: Arc::clone(&self.next_txn_id),
10912                change_wake: self.change_wake.clone(),
10913            }),
10914            table_name: Some(name.to_string()),
10915            auth: self.table_auth_checker(),
10916            read_only: self.read_only,
10917        };
10918        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
10919
10920        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
10921        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
10922        let schema_json = DdlOp::encode_schema(&schema)?;
10923        let ddl = match &state {
10924            TableState::Live => DdlOp::CreateTable {
10925                table_id,
10926                name: name.to_string(),
10927                schema_json,
10928            },
10929            TableState::Building {
10930                intended_name,
10931                query_id,
10932                created_at_unix_nanos,
10933                replaces_table_id,
10934            } => match replaces_table_id {
10935                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
10936                    table_id,
10937                    build_name: name.to_string(),
10938                    intended_name: intended_name.clone(),
10939                    query_id: query_id.clone(),
10940                    created_at_unix_nanos: *created_at_unix_nanos,
10941                    replaces_table_id: *replaces_table_id,
10942                    schema_json,
10943                },
10944                None => DdlOp::CreateBuildingTable {
10945                    table_id,
10946                    build_name: name.to_string(),
10947                    intended_name: intended_name.clone(),
10948                    query_id: query_id.clone(),
10949                    created_at_unix_nanos: *created_at_unix_nanos,
10950                    schema_json,
10951                },
10952            },
10953            TableState::Dropped { .. } => {
10954                return Err(MongrelError::InvalidArgument(
10955                    "cannot create a table in dropped state".into(),
10956                ));
10957            }
10958        };
10959        let mut next_catalog = self.catalog.read().clone();
10960        next_catalog.tables.push(CatalogEntry {
10961            table_id,
10962            name: name.to_string(),
10963            schema: schema.clone(),
10964            state: state.clone(),
10965            created_epoch: epoch.0,
10966        });
10967        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10968        let commit_seq = {
10969            let mut wal = self.shared_wal.lock();
10970            let append: Result<u64> = (|| {
10971                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
10972                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10973                wal.append_commit(txn_id, epoch, &[])
10974            })();
10975            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10976        };
10977        self.await_durable_commit(commit_seq, epoch)?;
10978        pending_table_dir.disarm();
10979
10980        // Publish the mounted table and catalog in memory even if the catalog
10981        // checkpoint fails after the WAL commit.
10982        self.tables
10983            .write()
10984            .insert(table_id, TableHandle::new(table));
10985        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10986        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10987        Ok(table_id)
10988    }
10989
10990    /// Logically drop a table, logging the DDL through the shared WAL first.
10991    pub fn drop_table(&self, name: &str) -> Result<()> {
10992        self.drop_table_with_epoch(name).map(|_| ())
10993    }
10994
10995    /// Logically drop a table and return the exact publication epoch.
10996    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
10997        self.drop_table_with_state(name, false, None)
10998    }
10999
11000    pub fn drop_table_with_epoch_controlled<F>(
11001        &self,
11002        name: &str,
11003        mut before_commit: F,
11004    ) -> Result<Epoch>
11005    where
11006        F: FnMut() -> Result<()>,
11007    {
11008        self.drop_table_with_state(name, false, Some(&mut before_commit))
11009    }
11010
11011    /// Discard an unpublished CTAS build.
11012    #[doc(hidden)]
11013    pub fn discard_building_table(&self, name: &str) -> Result<()> {
11014        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
11015            return Err(MongrelError::InvalidArgument(
11016                "not a CTAS building table".into(),
11017            ));
11018        }
11019        self.drop_table_with_state(name, true, None).map(|_| ())
11020    }
11021
11022    fn drop_table_with_state(
11023        &self,
11024        name: &str,
11025        building: bool,
11026        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11027    ) -> Result<Epoch> {
11028        use crate::wal::DdlOp;
11029        use std::sync::atomic::Ordering;
11030
11031        self.require(&crate::auth::Permission::Ddl)?;
11032        if self.poisoned.load(Ordering::Relaxed) {
11033            return Err(MongrelError::Other(
11034                "database poisoned by fsync error".into(),
11035            ));
11036        }
11037
11038        let _g = self.ddl_lock.lock();
11039        let _security_write = self.security_write()?;
11040        self.require(&crate::auth::Permission::Ddl)?;
11041        let table_id = {
11042            let cat = self.catalog.read();
11043            if building {
11044                cat.building(name)
11045            } else {
11046                cat.live(name)
11047            }
11048            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
11049            .table_id
11050        };
11051
11052        let commit_lock = Arc::clone(&self.commit_lock);
11053        let _c = commit_lock.lock();
11054        let epoch = self.epoch.bump_assigned();
11055        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11056        let txn_id = self.alloc_txn_id()?;
11057        let mut next_catalog = self.catalog.read().clone();
11058        let entry = next_catalog
11059            .tables
11060            .iter_mut()
11061            .find(|t| t.table_id == table_id)
11062            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11063        entry.state = TableState::Dropped { at_epoch: epoch.0 };
11064        next_catalog.triggers.retain(|trigger| {
11065            !matches!(
11066                &trigger.trigger.target,
11067                TriggerTarget::Table(target) if target == name
11068            )
11069        });
11070        next_catalog
11071            .materialized_views
11072            .retain(|definition| definition.name != name);
11073        next_catalog
11074            .security
11075            .rls_tables
11076            .retain(|table| table != name);
11077        next_catalog
11078            .security
11079            .policies
11080            .retain(|policy| policy.table != name);
11081        next_catalog
11082            .security
11083            .masks
11084            .retain(|mask| mask.table != name);
11085        for role in &mut next_catalog.roles {
11086            role.permissions
11087                .retain(|permission| permission_table(permission) != Some(name));
11088        }
11089        advance_security_version(&mut next_catalog)?;
11090        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11091        let commit_seq = {
11092            let mut wal = self.shared_wal.lock();
11093            if let Some(before_commit) = before_commit {
11094                before_commit()?;
11095            }
11096            let append: Result<u64> = (|| {
11097                wal.append(
11098                    txn_id,
11099                    table_id,
11100                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
11101                )?;
11102                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11103                wal.append_commit(txn_id, epoch, &[])
11104            })();
11105            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11106        };
11107        self.await_durable_commit(commit_seq, epoch)?;
11108
11109        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11110        self.tables.write().remove(&table_id);
11111        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
11112        Ok(epoch)
11113    }
11114
11115    /// Rename a live table. `name` must exist and `new_name` must not collide
11116    /// with any live table; both checks run under `ddl_lock` so they are atomic
11117    /// with the rename and with concurrent `create_table` existence checks (no
11118    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
11119    /// side-effects. The rename is logged to the shared WAL as
11120    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
11121    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
11122    /// the in-memory object does not move — only the catalog name changes).
11123    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
11124        self.rename_table_with_epoch(name, new_name).map(|_| ())
11125    }
11126
11127    /// Rename a table and return its exact publication epoch.
11128    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
11129        self.rename_table_with_epoch_inner(name, new_name, None)
11130    }
11131
11132    pub fn rename_table_with_epoch_controlled<F>(
11133        &self,
11134        name: &str,
11135        new_name: &str,
11136        mut before_commit: F,
11137    ) -> Result<Epoch>
11138    where
11139        F: FnMut() -> Result<()>,
11140    {
11141        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
11142    }
11143
11144    fn rename_table_with_epoch_inner(
11145        &self,
11146        name: &str,
11147        new_name: &str,
11148        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11149    ) -> Result<Epoch> {
11150        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11151            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11152        {
11153            return Err(MongrelError::InvalidArgument(
11154                "the CTAS building-table namespace is reserved".into(),
11155            ));
11156        }
11157        self.rename_table_with_state(name, new_name, false, None, before_commit)
11158    }
11159
11160    /// Atomically publish a hidden CTAS build under its intended live name.
11161    #[doc(hidden)]
11162    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
11163        self.publish_building_table_inner(build_name, new_name, None)
11164    }
11165
11166    #[doc(hidden)]
11167    pub fn publish_building_table_controlled<F>(
11168        &self,
11169        build_name: &str,
11170        new_name: &str,
11171        mut before_commit: F,
11172    ) -> Result<Epoch>
11173    where
11174        F: FnMut() -> Result<()>,
11175    {
11176        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
11177    }
11178
11179    fn publish_building_table_inner(
11180        &self,
11181        build_name: &str,
11182        new_name: &str,
11183        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11184    ) -> Result<Epoch> {
11185        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11186            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11187        {
11188            return Err(MongrelError::InvalidArgument(
11189                "invalid CTAS publish identity".into(),
11190            ));
11191        }
11192        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
11193    }
11194
11195    /// Atomically publish a hidden build and its materialized-view definition.
11196    #[doc(hidden)]
11197    pub fn publish_materialized_building_table(
11198        &self,
11199        build_name: &str,
11200        new_name: &str,
11201        definition: crate::catalog::MaterializedViewEntry,
11202    ) -> Result<Epoch> {
11203        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
11204    }
11205
11206    #[doc(hidden)]
11207    pub fn publish_materialized_building_table_controlled<F>(
11208        &self,
11209        build_name: &str,
11210        new_name: &str,
11211        definition: crate::catalog::MaterializedViewEntry,
11212        mut before_commit: F,
11213    ) -> Result<Epoch>
11214    where
11215        F: FnMut() -> Result<()>,
11216    {
11217        self.publish_materialized_building_table_inner(
11218            build_name,
11219            new_name,
11220            definition,
11221            Some(&mut before_commit),
11222        )
11223    }
11224
11225    fn publish_materialized_building_table_inner(
11226        &self,
11227        build_name: &str,
11228        new_name: &str,
11229        definition: crate::catalog::MaterializedViewEntry,
11230        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11231    ) -> Result<Epoch> {
11232        if definition.name != new_name || definition.query.trim().is_empty() {
11233            return Err(MongrelError::InvalidArgument(
11234                "invalid materialized-view publication".into(),
11235            ));
11236        }
11237        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
11238    }
11239
11240    /// Atomically replace a still-live table with its completed hidden rebuild.
11241    #[doc(hidden)]
11242    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
11243        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
11244    }
11245
11246    #[doc(hidden)]
11247    pub fn publish_rebuilding_table_controlled<F>(
11248        &self,
11249        build_name: &str,
11250        new_name: &str,
11251        mut before_commit: F,
11252    ) -> Result<Epoch>
11253    where
11254        F: FnMut() -> Result<()>,
11255    {
11256        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
11257    }
11258
11259    /// Atomically replace a live materialized-view table and its definition.
11260    #[doc(hidden)]
11261    pub fn publish_materialized_rebuilding_table_controlled<F>(
11262        &self,
11263        build_name: &str,
11264        new_name: &str,
11265        definition: crate::catalog::MaterializedViewEntry,
11266        mut before_commit: F,
11267    ) -> Result<Epoch>
11268    where
11269        F: FnMut() -> Result<()>,
11270    {
11271        self.publish_rebuilding_table_inner(
11272            build_name,
11273            new_name,
11274            Some(definition),
11275            Some(&mut before_commit),
11276        )
11277    }
11278
11279    fn publish_rebuilding_table_inner(
11280        &self,
11281        build_name: &str,
11282        new_name: &str,
11283        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11284        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11285    ) -> Result<Epoch> {
11286        use crate::wal::DdlOp;
11287
11288        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11289            || new_name.is_empty()
11290            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11291        {
11292            return Err(MongrelError::InvalidArgument(
11293                "invalid rebuilding-table publish identity".into(),
11294            ));
11295        }
11296        if materialized_view.as_ref().is_some_and(|definition| {
11297            definition.name != new_name || definition.query.trim().is_empty()
11298        }) {
11299            return Err(MongrelError::InvalidArgument(
11300                "invalid materialized-view replacement".into(),
11301            ));
11302        }
11303        self.require(&crate::auth::Permission::Ddl)?;
11304        if self.poisoned.load(Ordering::Relaxed) {
11305            return Err(MongrelError::Other(
11306                "database poisoned by fsync error".into(),
11307            ));
11308        }
11309
11310        let _ddl = self.ddl_lock.lock();
11311        let _security_write = self.security_write()?;
11312        let (table_id, replaced_table_id) = {
11313            let catalog = self.catalog.read();
11314            let build = catalog.building(build_name).ok_or_else(|| {
11315                MongrelError::NotFound(format!("building table {build_name:?} not found"))
11316            })?;
11317            let replaced_table_id = match &build.state {
11318                TableState::Building {
11319                    intended_name,
11320                    replaces_table_id: Some(replaced_table_id),
11321                    ..
11322                } if intended_name == new_name => *replaced_table_id,
11323                _ => {
11324                    return Err(MongrelError::InvalidArgument(format!(
11325                        "building table {build_name:?} is not a replacement for {new_name:?}"
11326                    )))
11327                }
11328            };
11329            if catalog
11330                .live(new_name)
11331                .is_none_or(|entry| entry.table_id != replaced_table_id)
11332            {
11333                return Err(MongrelError::Conflict(format!(
11334                    "table {new_name:?} changed while its replacement was built"
11335                )));
11336            }
11337            (build.table_id, replaced_table_id)
11338        };
11339
11340        let _commit = self.commit_lock.lock();
11341        let epoch = self.epoch.assigned().next();
11342        let txn_id = self.alloc_txn_id()?;
11343        let mut next_catalog = self.catalog.read().clone();
11344        apply_rebuilding_publish(
11345            &mut next_catalog,
11346            table_id,
11347            replaced_table_id,
11348            new_name,
11349            epoch.0,
11350        )?;
11351        if let Some(definition) = materialized_view.as_mut() {
11352            definition.last_refresh_epoch = epoch.0;
11353        }
11354        let materialized_view_json = materialized_view
11355            .as_ref()
11356            .map(DdlOp::encode_materialized_view)
11357            .transpose()?;
11358        if let Some(definition) = materialized_view {
11359            if let Some(existing) = next_catalog
11360                .materialized_views
11361                .iter_mut()
11362                .find(|existing| existing.name == definition.name)
11363            {
11364                *existing = definition;
11365            } else {
11366                next_catalog.materialized_views.push(definition);
11367            }
11368        }
11369        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11370        if let Some(before_commit) = before_commit {
11371            before_commit()?;
11372        }
11373        let assigned_epoch = self.epoch.bump_assigned();
11374        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
11375        if assigned_epoch != epoch {
11376            return Err(MongrelError::Conflict(
11377                "commit epoch changed while sequencer lock was held".into(),
11378            ));
11379        }
11380        let commit_seq = {
11381            let mut wal = self.shared_wal.lock();
11382            let append: Result<u64> = (|| {
11383                wal.append(
11384                    txn_id,
11385                    table_id,
11386                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
11387                        table_id,
11388                        replaced_table_id,
11389                        new_name: new_name.to_string(),
11390                    }),
11391                )?;
11392                if let Some(definition_json) = materialized_view_json {
11393                    wal.append(
11394                        txn_id,
11395                        table_id,
11396                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11397                            name: new_name.to_string(),
11398                            definition_json,
11399                        }),
11400                    )?;
11401                }
11402                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11403                wal.append_commit(txn_id, epoch, &[])
11404            })();
11405            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11406        };
11407        self.await_durable_commit(commit_seq, epoch)?;
11408
11409        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11410        self.tables.write().remove(&replaced_table_id);
11411        if let Some(table) = self.tables.read().get(&table_id) {
11412            table.lock().set_catalog_name(new_name.to_string());
11413        }
11414        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
11415        Ok(epoch)
11416    }
11417
11418    fn rename_table_with_state(
11419        &self,
11420        name: &str,
11421        new_name: &str,
11422        building: bool,
11423        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11424        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11425    ) -> Result<Epoch> {
11426        use crate::wal::DdlOp;
11427        use std::sync::atomic::Ordering;
11428
11429        self.require(&crate::auth::Permission::Ddl)?;
11430        if self.poisoned.load(Ordering::Relaxed) {
11431            return Err(MongrelError::Other(
11432                "database poisoned by fsync error".into(),
11433            ));
11434        }
11435
11436        // A no-op rename short-circuits before any locking, so it can never
11437        // trip the "target already exists" check (the source *is* that name).
11438        if name == new_name {
11439            return Ok(self.visible_epoch());
11440        }
11441        if new_name.is_empty() {
11442            return Err(MongrelError::InvalidArgument(
11443                "rename_table: new name must not be empty".into(),
11444            ));
11445        }
11446
11447        let _g = self.ddl_lock.lock();
11448        let _security_write = self.security_write()?;
11449        self.require(&crate::auth::Permission::Ddl)?;
11450        let table_id = {
11451            let cat = self.catalog.read();
11452            let src = if building {
11453                cat.building(name)
11454            } else {
11455                cat.live(name)
11456            }
11457            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11458            if building
11459                && !matches!(
11460                    &src.state,
11461                    TableState::Building { intended_name, .. } if intended_name == new_name
11462                )
11463            {
11464                return Err(MongrelError::InvalidArgument(format!(
11465                    "building table {name:?} is not reserved for {new_name:?}"
11466                )));
11467            }
11468            // Target must be free. Checked under ddl_lock, which every other
11469            // DDL (create/rename/drop) also holds, so a concurrent operation
11470            // cannot claim `new_name` between this check and the catalog write.
11471            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
11472                return Err(MongrelError::InvalidArgument(format!(
11473                    "rename_table: a table named {new_name:?} already exists"
11474                )));
11475            }
11476            src.table_id
11477        };
11478
11479        let commit_lock = Arc::clone(&self.commit_lock);
11480        let _c = commit_lock.lock();
11481        let epoch = self.epoch.bump_assigned();
11482        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11483        let txn_id = self.alloc_txn_id()?;
11484        if let Some(definition) = materialized_view.as_mut() {
11485            definition.last_refresh_epoch = epoch.0;
11486        }
11487        let materialized_view_json = materialized_view
11488            .as_ref()
11489            .map(DdlOp::encode_materialized_view)
11490            .transpose()?;
11491        let mut next_catalog = self.catalog.read().clone();
11492        let entry = next_catalog
11493            .tables
11494            .iter_mut()
11495            .find(|t| t.table_id == table_id)
11496            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11497        entry.name = new_name.to_string();
11498        if building {
11499            entry.state = TableState::Live;
11500        }
11501        for trigger in &mut next_catalog.triggers {
11502            if matches!(
11503                &trigger.trigger.target,
11504                TriggerTarget::Table(target) if target == name
11505            ) {
11506                trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
11507            }
11508        }
11509        if let Some(definition) = next_catalog
11510            .materialized_views
11511            .iter_mut()
11512            .find(|definition| definition.name == name)
11513        {
11514            definition.name = new_name.to_string();
11515        }
11516        if let Some(definition) = materialized_view.take() {
11517            next_catalog.materialized_views.push(definition);
11518        }
11519        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11520        for table in &mut next_catalog.security.rls_tables {
11521            if table == name {
11522                *table = new_name.to_string();
11523            }
11524        }
11525        for policy in &mut next_catalog.security.policies {
11526            if policy.table == name {
11527                policy.table = new_name.to_string();
11528            }
11529        }
11530        for mask in &mut next_catalog.security.masks {
11531            if mask.table == name {
11532                mask.table = new_name.to_string();
11533            }
11534        }
11535        for role in &mut next_catalog.roles {
11536            for permission in &mut role.permissions {
11537                rename_permission_table(permission, name, new_name);
11538            }
11539        }
11540        advance_security_version(&mut next_catalog)?;
11541        let ddl = if building {
11542            DdlOp::PublishBuildingTable {
11543                table_id,
11544                new_name: new_name.to_string(),
11545            }
11546        } else {
11547            DdlOp::RenameTable {
11548                table_id,
11549                new_name: new_name.to_string(),
11550            }
11551        };
11552        let commit_seq = {
11553            let mut wal = self.shared_wal.lock();
11554            if let Some(before_commit) = before_commit {
11555                before_commit()?;
11556            }
11557            let append: Result<u64> = (|| {
11558                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
11559                if let Some(definition_json) = materialized_view_json {
11560                    wal.append(
11561                        txn_id,
11562                        table_id,
11563                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11564                            name: new_name.to_string(),
11565                            definition_json,
11566                        }),
11567                    )?;
11568                }
11569                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11570                wal.append_commit(txn_id, epoch, &[])
11571            })();
11572            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11573        };
11574        self.await_durable_commit(commit_seq, epoch)?;
11575
11576        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11577        // The in-memory table object is keyed by table_id, not name, so it does
11578        // not move and live TableHandles remain valid.
11579        if let Some(table) = self.tables.read().get(&table_id) {
11580            table.lock().set_catalog_name(new_name.to_string());
11581        }
11582        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
11583        Ok(epoch)
11584    }
11585
11586    pub fn alter_column(
11587        &self,
11588        table_name: &str,
11589        column_name: &str,
11590        change: AlterColumn,
11591    ) -> Result<ColumnDef> {
11592        self.alter_column_with_epoch(table_name, column_name, change)
11593            .map(|(column, _)| column)
11594    }
11595
11596    pub fn alter_column_with_epoch(
11597        &self,
11598        table_name: &str,
11599        column_name: &str,
11600        change: AlterColumn,
11601    ) -> Result<(ColumnDef, Option<Epoch>)> {
11602        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
11603    }
11604
11605    /// Cooperatively prepare an ALTER and fence each durable commit separately.
11606    /// `after_commit(Some(epoch))` follows an exact durable outcome;
11607    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
11608    /// for every successful `before_commit` callback.
11609    pub fn alter_column_with_epoch_controlled<B, A>(
11610        &self,
11611        table_name: &str,
11612        column_name: &str,
11613        change: AlterColumn,
11614        control: &crate::ExecutionControl,
11615        mut before_commit: B,
11616        mut after_commit: A,
11617    ) -> Result<(ColumnDef, Option<Epoch>)>
11618    where
11619        B: FnMut() -> Result<()>,
11620        A: FnMut(Option<Epoch>) -> Result<()>,
11621    {
11622        self.alter_column_with_epoch_inner(
11623            table_name,
11624            column_name,
11625            change,
11626            Some(control),
11627            Some(&mut before_commit),
11628            Some(&mut after_commit),
11629        )
11630    }
11631
11632    #[allow(clippy::too_many_arguments)]
11633    fn alter_column_with_epoch_inner(
11634        &self,
11635        table_name: &str,
11636        column_name: &str,
11637        change: AlterColumn,
11638        control: Option<&crate::ExecutionControl>,
11639        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11640        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
11641    ) -> Result<(ColumnDef, Option<Epoch>)> {
11642        use crate::wal::DdlOp;
11643        use std::sync::atomic::Ordering;
11644
11645        self.require(&crate::auth::Permission::Ddl)?;
11646        commit_prepare_checkpoint(control, 0)?;
11647        if self.poisoned.load(Ordering::Relaxed) {
11648            return Err(MongrelError::Other(
11649                "database poisoned by fsync error".into(),
11650            ));
11651        }
11652
11653        let _g = self.ddl_lock.lock();
11654        let table_id = {
11655            let cat = self.catalog.read();
11656            cat.live(table_name)
11657                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11658                .table_id
11659        };
11660        let handle =
11661            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11662                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11663            })?;
11664
11665        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
11666        // declared default, backfill existing NULL/absent cells as one durable
11667        // transaction before logging the metadata change. A crash between the
11668        // two commits leaves a harmless nullable-but-filled column; retry is
11669        // idempotent because only remaining NULLs are touched.
11670        let backfill = {
11671            let table = handle.lock();
11672            let old = table
11673                .schema()
11674                .column(column_name)
11675                .cloned()
11676                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
11677            let next_flags = change.flags.unwrap_or(old.flags);
11678            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
11679                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
11680                && old.default_value.is_some()
11681            {
11682                let snapshot = Snapshot::at(self.epoch.visible());
11683                let mut updates = Vec::new();
11684                let rows = match control {
11685                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
11686                    None => table.visible_rows(snapshot)?,
11687                };
11688                for (row_index, row) in rows.into_iter().enumerate() {
11689                    commit_prepare_checkpoint(control, row_index)?;
11690                    if row
11691                        .columns
11692                        .get(&old.id)
11693                        .is_some_and(|value| !matches!(value, Value::Null))
11694                    {
11695                        continue;
11696                    }
11697                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
11698                    table.apply_defaults(&mut cells)?;
11699                    updates.push((
11700                        table_id,
11701                        crate::txn::Staged::Update {
11702                            row_id: row.row_id,
11703                            new_row: cells,
11704                            changed_columns: vec![old.id],
11705                        },
11706                    ));
11707                }
11708                updates
11709            } else {
11710                Vec::new()
11711            }
11712        };
11713        let durable_epoch = std::cell::Cell::new(None);
11714        let backfill_epoch = if backfill.is_empty() {
11715            None
11716        } else {
11717            let (principal, catalog_bound) = self.transaction_principal_snapshot();
11718            let txn_id = self.alloc_txn_id()?;
11719            let mut entered_fence = false;
11720            let commit_result = match (control, before_commit.as_deref_mut()) {
11721                (Some(control), Some(before_commit)) => self
11722                    .commit_transaction_with_external_states_controlled(
11723                        txn_id,
11724                        self.epoch.visible(),
11725                        backfill,
11726                        Vec::new(),
11727                        Vec::new(),
11728                        principal.clone(),
11729                        catalog_bound,
11730                        None,
11731                        control,
11732                        &mut || {
11733                            before_commit()?;
11734                            entered_fence = true;
11735                            Ok(())
11736                        },
11737                    )
11738                    .map(|(epoch, _)| epoch),
11739                _ => self
11740                    .commit_transaction_with_external_states(
11741                        txn_id,
11742                        self.epoch.visible(),
11743                        backfill,
11744                        Vec::new(),
11745                        Vec::new(),
11746                        principal,
11747                        catalog_bound,
11748                        None,
11749                    )
11750                    .map(|(epoch, _)| epoch),
11751            };
11752            let commit_result = if entered_fence {
11753                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11754            } else {
11755                commit_result
11756            };
11757            match &commit_result {
11758                Ok(epoch) => durable_epoch.set(Some(*epoch)),
11759                Err(MongrelError::DurableCommit { epoch, .. }) => {
11760                    durable_epoch.set(Some(Epoch(*epoch)));
11761                }
11762                Err(_) => {}
11763            }
11764            Some(commit_result?)
11765        };
11766        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
11767            let _security_write = self.security_write()?;
11768            self.require(&crate::auth::Permission::Ddl)?;
11769            if self
11770                .catalog
11771                .read()
11772                .live(table_name)
11773                .is_none_or(|entry| entry.table_id != table_id)
11774            {
11775                return Err(MongrelError::Conflict(format!(
11776                    "table {table_name:?} changed during ALTER"
11777                )));
11778            }
11779            let mut table = handle.lock();
11780            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
11781            let renamed_column = (column.name != column_name).then(|| column.name.clone());
11782            let Some(prepared_schema) = prepared_schema else {
11783                return Ok((column, backfill_epoch));
11784            };
11785
11786            let commit_lock = Arc::clone(&self.commit_lock);
11787            let _c = commit_lock.lock();
11788            let epoch = self.epoch.bump_assigned();
11789            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11790            let txn_id = self.alloc_txn_id()?;
11791            let column_json = DdlOp::encode_column(&column)?;
11792            let mut next_catalog = self.catalog.read().clone();
11793            let catalog_entry_index = next_catalog
11794                .tables
11795                .iter()
11796                .position(|entry| entry.table_id == table_id)
11797                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
11798            if let Some(new_column_name) = &renamed_column {
11799                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
11800                    commit_prepare_checkpoint(control, trigger_index)?;
11801                    if matches!(
11802                        &trigger.trigger.target,
11803                        TriggerTarget::Table(target) if target == table_name
11804                    ) {
11805                        trigger.trigger = trigger.trigger.renamed_update_column(
11806                            column_name,
11807                            new_column_name.clone(),
11808                            epoch.0,
11809                        )?;
11810                    }
11811                }
11812                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
11813                    commit_prepare_checkpoint(control, role_index)?;
11814                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
11815                        commit_prepare_checkpoint(control, permission_index)?;
11816                        rename_permission_column(
11817                            permission,
11818                            table_name,
11819                            column_name,
11820                            new_column_name,
11821                        );
11822                    }
11823                }
11824                advance_security_version(&mut next_catalog)?;
11825            }
11826            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
11827            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11828            commit_prepare_checkpoint(control, 0)?;
11829            let mut entered_fence = false;
11830            if let Some(before_commit) = before_commit.as_deref_mut() {
11831                before_commit()?;
11832                entered_fence = true;
11833            }
11834            let commit_result: Result<Epoch> = (|| {
11835                let commit_seq = {
11836                    let mut wal = self.shared_wal.lock();
11837                    let append: Result<u64> = (|| {
11838                        wal.append(
11839                            txn_id,
11840                            table_id,
11841                            crate::wal::Op::Ddl(DdlOp::AlterTable {
11842                                table_id,
11843                                column_json,
11844                            }),
11845                        )?;
11846                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11847                        wal.append_commit(txn_id, epoch, &[])
11848                    })();
11849                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11850                };
11851                self.await_durable_commit(commit_seq, epoch)?;
11852                durable_epoch.set(Some(epoch));
11853
11854                table.apply_altered_schema_prepared(prepared_schema);
11855                let schema = table.schema().clone();
11856                let table_checkpoint = table.checkpoint_altered_schema();
11857                drop(table);
11858                next_catalog.tables[catalog_entry_index].schema = schema;
11859                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11860                let catalog_result =
11861                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
11862                let security_version = next_catalog.security_version;
11863                *self.catalog.write() = next_catalog;
11864                if renamed_column.is_some() {
11865                    self.security_coordinator
11866                        .version
11867                        .store(security_version, Ordering::Release);
11868                }
11869                self.epoch.publish_in_order(epoch);
11870                _epoch_guard.disarm();
11871                if let Err(error) = table_checkpoint.and(catalog_result) {
11872                    self.poisoned.store(true, Ordering::Relaxed);
11873                    return Err(MongrelError::DurableCommit {
11874                        epoch: epoch.0,
11875                        message: error.to_string(),
11876                    });
11877                }
11878                Ok(epoch)
11879            })();
11880            let commit_result = if entered_fence {
11881                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11882            } else {
11883                commit_result
11884            };
11885            let epoch = commit_result?;
11886            Ok((column, Some(epoch)))
11887        })();
11888        result.map_err(|error| match (durable_epoch.get(), error) {
11889            (_, error @ MongrelError::DurableCommit { .. }) => error,
11890            (Some(epoch), error) => MongrelError::DurableCommit {
11891                epoch: epoch.0,
11892                message: error.to_string(),
11893            },
11894            (None, error) => error,
11895        })
11896    }
11897
11898    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
11899    /// replication. Duration is in nanoseconds.
11900    pub fn set_table_ttl(
11901        &self,
11902        table_name: &str,
11903        column_name: &str,
11904        duration_nanos: u64,
11905    ) -> Result<crate::manifest::TtlPolicy> {
11906        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
11907        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
11908    }
11909
11910    /// Set TTL metadata on a hidden build before it is published.
11911    #[doc(hidden)]
11912    pub fn set_building_table_ttl(
11913        &self,
11914        table_name: &str,
11915        column_name: &str,
11916        duration_nanos: u64,
11917    ) -> Result<crate::manifest::TtlPolicy> {
11918        let policy = self.replace_table_ttl_with_state(
11919            table_name,
11920            Some((column_name, duration_nanos)),
11921            true,
11922        )?;
11923        policy
11924            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
11925    }
11926
11927    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
11928        self.replace_table_ttl(table_name, None)?;
11929        Ok(())
11930    }
11931
11932    fn replace_table_ttl(
11933        &self,
11934        table_name: &str,
11935        requested: Option<(&str, u64)>,
11936    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11937        self.replace_table_ttl_with_state(table_name, requested, false)
11938    }
11939
11940    fn replace_table_ttl_with_state(
11941        &self,
11942        table_name: &str,
11943        requested: Option<(&str, u64)>,
11944        building: bool,
11945    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11946        use crate::wal::DdlOp;
11947        use std::sync::atomic::Ordering;
11948
11949        self.require(&crate::auth::Permission::Ddl)?;
11950        if self.poisoned.load(Ordering::Relaxed) {
11951            return Err(MongrelError::Other(
11952                "database poisoned by fsync error".into(),
11953            ));
11954        }
11955
11956        let _g = self.ddl_lock.lock();
11957        let _security_write = self.security_write()?;
11958        self.require(&crate::auth::Permission::Ddl)?;
11959        let table_id = {
11960            let cat = self.catalog.read();
11961            if building {
11962                cat.building(table_name)
11963            } else {
11964                cat.live(table_name)
11965            }
11966            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11967            .table_id
11968        };
11969        let handle =
11970            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11971                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11972            })?;
11973        let mut table = handle.lock();
11974        let policy = match requested {
11975            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
11976            None => None,
11977        };
11978        if table.ttl() == policy {
11979            return Ok(policy);
11980        }
11981
11982        let commit_lock = Arc::clone(&self.commit_lock);
11983        let _c = commit_lock.lock();
11984        let epoch = self.epoch.bump_assigned();
11985        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11986        let txn_id = self.alloc_txn_id()?;
11987        let policy_json = DdlOp::encode_ttl(policy)?;
11988        let mut next_catalog = self.catalog.read().clone();
11989        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11990        let commit_seq = {
11991            let mut wal = self.shared_wal.lock();
11992            let append: Result<u64> = (|| {
11993                wal.append(
11994                    txn_id,
11995                    table_id,
11996                    crate::wal::Op::Ddl(DdlOp::SetTtl {
11997                        table_id,
11998                        policy_json,
11999                    }),
12000                )?;
12001                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12002                wal.append_commit(txn_id, epoch, &[])
12003            })();
12004            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
12005        };
12006        self.await_durable_commit(commit_seq, epoch)?;
12007
12008        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
12009        drop(table);
12010        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
12011            publish_error.get_or_insert(error);
12012        }
12013        self.finish_durable_publish(epoch, &mut _epoch_guard, publish_error.map_or(Ok(()), Err))?;
12014        Ok(policy)
12015    }
12016
12017    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
12018    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
12019    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
12020    ///
12021    /// Returns the number of items reclaimed.
12022    pub fn gc(&self) -> Result<usize> {
12023        let control = crate::ExecutionControl::new(None);
12024        self.gc_controlled(&control, || true)
12025    }
12026
12027    /// Discover reclaimable state cooperatively, then cross one publication
12028    /// boundary immediately before the first irreversible deletion.
12029    #[doc(hidden)]
12030    pub fn gc_controlled<F>(
12031        &self,
12032        control: &crate::ExecutionControl,
12033        before_publish: F,
12034    ) -> Result<usize>
12035    where
12036        F: FnOnce() -> bool,
12037    {
12038        self.gc_controlled_with_receipt(control, before_publish)
12039            .map(|(reclaimed, _)| reclaimed)
12040    }
12041
12042    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
12043    /// return that snapshot if an irreversible deletion was attempted.
12044    #[doc(hidden)]
12045    pub fn gc_controlled_with_receipt<F>(
12046        &self,
12047        control: &crate::ExecutionControl,
12048        before_publish: F,
12049    ) -> Result<(usize, Option<MaintenanceReceipt>)>
12050    where
12051        F: FnOnce() -> bool,
12052    {
12053        enum Candidate {
12054            Directory(PathBuf),
12055            File(PathBuf),
12056        }
12057
12058        self.require(&crate::auth::Permission::Ddl)?;
12059        let _ddl = self.ddl_lock.lock();
12060        self.require(&crate::auth::Permission::Ddl)?;
12061        control.checkpoint()?;
12062        let maintenance_epoch = self.epoch.visible();
12063        let min_active = self.snapshots.min_active(maintenance_epoch).0;
12064        let mut candidates = Vec::new();
12065
12066        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
12067        let cat = self.catalog.read();
12068        for (entry_index, entry) in cat.tables.iter().enumerate() {
12069            if entry_index % 256 == 0 {
12070                control.checkpoint()?;
12071            }
12072            if let TableState::Dropped { at_epoch } = entry.state {
12073                if at_epoch <= min_active {
12074                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
12075                    if tdir.exists() {
12076                        candidates.push(Candidate::Directory(tdir));
12077                    }
12078                }
12079            }
12080        }
12081        drop(cat);
12082
12083        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
12084        // in-flight spill's dir (deleting it would lose the pending run and fail
12085        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
12086        // skip any id still registered in `active_spills`.
12087        let cat = self.catalog.read();
12088        for (entry_index, entry) in cat.tables.iter().enumerate() {
12089            if entry_index % 256 == 0 {
12090                control.checkpoint()?;
12091            }
12092            if !matches!(entry.state, TableState::Live) {
12093                continue;
12094            }
12095            let txn_dir = self
12096                .root
12097                .join(TABLES_DIR)
12098                .join(entry.table_id.to_string())
12099                .join("_txn");
12100            if !txn_dir.exists() {
12101                continue;
12102            }
12103            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
12104                if sub_index % 256 == 0 {
12105                    control.checkpoint()?;
12106                }
12107                let sub = sub?;
12108                let name = sub.file_name();
12109                let Some(name) = name.to_str() else { continue };
12110                // A non-numeric entry can't belong to a live txn — sweep it.
12111                let is_active = name
12112                    .parse::<u64>()
12113                    .map(|id| self.active_spills.is_active(id))
12114                    .unwrap_or(false);
12115                if is_active {
12116                    continue;
12117                }
12118                candidates.push(Candidate::Directory(sub.path()));
12119            }
12120        }
12121        drop(cat);
12122
12123        let external_names = {
12124            let cat = self.catalog.read();
12125            cat.external_tables
12126                .iter()
12127                .map(|entry| entry.name.clone())
12128                .collect::<std::collections::HashSet<_>>()
12129        };
12130        let vtab_dir = self.root.join(VTAB_DIR);
12131        if vtab_dir.exists() {
12132            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
12133                if entry_index % 256 == 0 {
12134                    control.checkpoint()?;
12135                }
12136                let entry = entry?;
12137                let name = entry.file_name();
12138                let Some(name) = name.to_str() else { continue };
12139                if external_names.contains(name) {
12140                    continue;
12141                }
12142                let path = entry.path();
12143                if path.is_dir() {
12144                    candidates.push(Candidate::Directory(path));
12145                } else {
12146                    candidates.push(Candidate::File(path));
12147                }
12148            }
12149        }
12150
12151        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
12152        // can still need (spec §6.4). Each table deletes its own retired files
12153        // gated on `min_active` and persists its manifest.
12154        let tables = self
12155            .tables
12156            .read()
12157            .iter()
12158            .map(|(table_id, handle)| (*table_id, handle.clone()))
12159            .collect::<Vec<_>>();
12160        let mut retiring = Vec::new();
12161        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
12162            if table_index % 256 == 0 {
12163                control.checkpoint()?;
12164            }
12165            let backup_pinned: HashSet<u128> = self
12166                .backup_pins
12167                .lock()
12168                .keys()
12169                .filter_map(|(pinned_table, run_id)| {
12170                    (*pinned_table == *table_id).then_some(*run_id)
12171                })
12172                .collect();
12173            if handle
12174                .lock()
12175                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
12176            {
12177                retiring.push((handle.clone(), backup_pinned));
12178            }
12179        }
12180
12181        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
12182        // segment on every reopen without truncating the prior ones, so rotated
12183        // segments accumulate. Once every live table's committed data is durable
12184        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
12185        // (non-active) segments are redundant for recovery and safe to delete —
12186        // an in-flight txn only ever appends to the active segment, which is
12187        // never deleted.
12188        let all_durable = self.active_spills.is_idle()
12189            && tables.iter().all(|(_, handle)| {
12190                let g = handle.lock();
12191                g.memtable_len() == 0 && g.mutable_run_len() == 0
12192            });
12193        let retain = self
12194            .replication_wal_retention_segments
12195            .load(std::sync::atomic::Ordering::Relaxed);
12196        let reap_wal = all_durable
12197            && self
12198                .shared_wal
12199                .lock()
12200                .has_gc_segments_retain_recent(retain)?;
12201
12202        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
12203            return Ok((0, None));
12204        }
12205        control.checkpoint()?;
12206        if !before_publish() {
12207            return Err(MongrelError::Cancelled);
12208        }
12209
12210        let mut reclaimed = 0;
12211        for candidate in candidates {
12212            match candidate {
12213                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
12214                Candidate::File(path) => std::fs::remove_file(path)?,
12215            }
12216            reclaimed += 1;
12217        }
12218        for (handle, backup_pinned) in retiring {
12219            reclaimed += handle
12220                .lock()
12221                .reap_retiring(Epoch(min_active), &backup_pinned)?;
12222        }
12223        if reap_wal {
12224            reclaimed += self
12225                .shared_wal
12226                .lock()
12227                .gc_segments_retain_recent(u64::MAX, retain)?;
12228        }
12229
12230        Ok((
12231            reclaimed,
12232            Some(MaintenanceReceipt {
12233                epoch: maintenance_epoch,
12234            }),
12235        ))
12236    }
12237
12238    /// Produce a deterministic-stable byte image of the database directory.
12239    ///
12240    /// After `checkpoint()`:
12241    ///   - All pending writes are flushed to sorted runs (no memtable data).
12242    ///   - Each table is compacted to a single sorted run (no run fragmentation).
12243    ///   - All non-active WAL segments are deleted (data is durable in runs).
12244    ///   - The active WAL segment is rotated to a fresh empty segment.
12245    ///   - Dropped-table directories are removed.
12246    ///   - All manifests + catalog are persisted.
12247    ///
12248    /// The resulting directory is byte-stable: `git add` captures a snapshot
12249    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
12250    /// no unbounded segment growth, no mutable-run spill files.
12251    ///
12252    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
12253    /// It does NOT clear the exclusive lock — the caller still owns the
12254    /// database handle.
12255    pub fn checkpoint(&self) -> Result<()> {
12256        self.checkpoint_controlled(|| Ok(()))
12257    }
12258
12259    /// Strict checkpoint with a deterministic test hook after every table is
12260    /// flushed/compacted but before WAL replacement.
12261    #[doc(hidden)]
12262    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
12263    where
12264        F: FnOnce() -> Result<()>,
12265    {
12266        self.require(&crate::auth::Permission::Ddl)?;
12267        // Block cross-table commits and DDL for the full operation. Locking all
12268        // mounted handles also excludes direct `Table` commits, which do not
12269        // enter the database replication barrier.
12270        let _replication = self.replication_barrier.write();
12271        let _ddl = self.ddl_lock.lock();
12272        let _security = self.security_coordinator.gate.read();
12273        self.require(&crate::auth::Permission::Ddl)?;
12274
12275        let mut handles = self
12276            .tables
12277            .read()
12278            .iter()
12279            .map(|(table_id, handle)| (*table_id, handle.clone()))
12280            .collect::<Vec<_>>();
12281        handles.sort_by_key(|(table_id, _)| *table_id);
12282        let mut tables = handles
12283            .iter()
12284            .map(|(table_id, handle)| (*table_id, handle.lock()))
12285            .collect::<Vec<_>>();
12286
12287        // Strict flush. Any error leaves the old WAL recovery source intact.
12288        for (_, table) in &mut tables {
12289            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
12290            {
12291                table.force_flush()?;
12292            }
12293        }
12294
12295        // Strict compaction. Checkpoint never reports a stable image after a
12296        // skipped failure.
12297        for (_, table) in &mut tables {
12298            if table.run_count() >= 2 || table.should_compact() {
12299                table.compact()?;
12300            }
12301        }
12302
12303        before_wal_reset()?;
12304
12305        // Reap table-local retired runs while every table remains quiesced.
12306        let maintenance_epoch = self.epoch.visible();
12307        let min_active = self.snapshots.min_active(maintenance_epoch);
12308        for (table_id, table) in &mut tables {
12309            let backup_pinned: HashSet<u128> = self
12310                .backup_pins
12311                .lock()
12312                .keys()
12313                .filter_map(|(pinned_table, run_id)| {
12314                    (*pinned_table == *table_id).then_some(*run_id)
12315                })
12316                .collect();
12317            table.reap_retiring(min_active, &backup_pinned)?;
12318        }
12319
12320        // Publish a fresh synced active WAL, then durably reap every older
12321        // segment. This point is reached only after every strict flush succeeds.
12322        self.shared_wal.lock().reset_after_checkpoint()?;
12323
12324        // Remove catalog-unreachable directories and stale transaction state.
12325        let catalog_snapshot = self.catalog.read().clone();
12326        for entry in &catalog_snapshot.tables {
12327            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
12328                crate::durable_file::remove_directory_all(
12329                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
12330                )?;
12331            }
12332            if !matches!(entry.state, TableState::Live) {
12333                continue;
12334            }
12335            let transaction_dir = self
12336                .root
12337                .join(TABLES_DIR)
12338                .join(entry.table_id.to_string())
12339                .join("_txn");
12340            if transaction_dir.is_dir() {
12341                for child in std::fs::read_dir(&transaction_dir)? {
12342                    let child = child?;
12343                    let active = child
12344                        .file_name()
12345                        .to_str()
12346                        .and_then(|name| name.parse::<u64>().ok())
12347                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
12348                    if !active {
12349                        crate::durable_file::remove_directory_all(&child.path())?;
12350                    }
12351                }
12352            }
12353        }
12354        let external_names = catalog_snapshot
12355            .external_tables
12356            .iter()
12357            .map(|entry| entry.name.as_str())
12358            .collect::<HashSet<_>>();
12359        let external_root = self.root.join(VTAB_DIR);
12360        if external_root.is_dir() {
12361            for entry in std::fs::read_dir(&external_root)? {
12362                let entry = entry?;
12363                let name = entry.file_name();
12364                if name
12365                    .to_str()
12366                    .is_some_and(|name| external_names.contains(name))
12367                {
12368                    continue;
12369                }
12370                if entry.file_type()?.is_dir() {
12371                    crate::durable_file::remove_directory_all(&entry.path())?;
12372                } else {
12373                    std::fs::remove_file(entry.path())?;
12374                    crate::durable_file::sync_directory(&external_root)?;
12375                }
12376            }
12377        }
12378
12379        // Final authoritative metadata checkpoint while all writers remain
12380        // excluded.
12381        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
12382        let visible = self.epoch.visible();
12383        for (_, table) in &tables {
12384            table.persist_manifest(visible)?;
12385        }
12386
12387        Ok(())
12388    }
12389    fn alloc_txn_id(&self) -> Result<u64> {
12390        self.ensure_owner_process()?;
12391        crate::txn::allocate_txn_id(&self.next_txn_id)
12392    }
12393
12394    /// Set the per-table spill threshold (bytes). When a transaction's staged
12395    /// bytes for a single table exceed this, the rows are written as a
12396    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
12397    pub fn set_spill_threshold(&self, bytes: u64) {
12398        self.spill_threshold
12399            .store(bytes, std::sync::atomic::Ordering::Relaxed);
12400    }
12401
12402    /// Test-only: install a hook invoked after a transaction writes its spill
12403    /// runs but before the sequencer, so a test can race `gc()` against an
12404    /// in-flight spill. Not part of the stable API.
12405    #[doc(hidden)]
12406    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12407        *self.spill_hook.lock() = Some(Box::new(f));
12408    }
12409
12410    /// Test-only: install a hook invoked while a spilled commit holds the
12411    /// security read gate and before it appends to the WAL.
12412    #[doc(hidden)]
12413    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12414        *self.security_commit_hook.lock() = Some(Box::new(f));
12415    }
12416
12417    /// Test-only: install a hook after transaction preparation and before the
12418    /// commit sequencer validates catalog generations.
12419    #[doc(hidden)]
12420    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12421        *self.catalog_commit_hook.lock() = Some(Box::new(f));
12422    }
12423
12424    /// Test-only: pause an online backup after its consistent boundary is
12425    /// captured but before the pinned immutable runs are copied.
12426    #[doc(hidden)]
12427    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12428        *self.backup_hook.lock() = Some(Box::new(f));
12429    }
12430
12431    /// Test-only: pause WAL extraction before its final principal recheck.
12432    #[doc(hidden)]
12433    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12434        *self.replication_hook.lock() = Some(Box::new(f));
12435    }
12436
12437    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
12438    /// this stays well below the number of committed transactions when commits
12439    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
12440    #[doc(hidden)]
12441    pub fn __wal_group_sync_count(&self) -> u64 {
12442        self.shared_wal.lock().group_sync_count()
12443    }
12444
12445    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
12446    /// contract that an fsync error would trigger in production.
12447    #[doc(hidden)]
12448    pub fn __poison(&self) {
12449        self.poisoned
12450            .store(true, std::sync::atomic::Ordering::Relaxed);
12451    }
12452
12453    /// Verify multi-table integrity (spec §16). For every live table this:
12454    /// authenticates the manifest; opens each `RunRef`'s file through
12455    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
12456    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
12457    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
12458    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
12459    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
12460    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
12461    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
12462    ///
12463    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
12464    /// full body, so this is an integrity tool, not a hot path.
12465    pub fn check(&self) -> Vec<CheckIssue> {
12466        match self.check_inner(None) {
12467            Ok(issues) => issues,
12468            Err(error) => vec![CheckIssue {
12469                table_id: WAL_TABLE_ID,
12470                table_name: "shared WAL".into(),
12471                severity: "error".into(),
12472                description: error.to_string(),
12473            }],
12474        }
12475    }
12476
12477    /// Integrity check with cooperative cancellation between tables and runs.
12478    #[doc(hidden)]
12479    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
12480        self.check_inner(Some(control))
12481    }
12482
12483    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
12484        let mut issues = Vec::new();
12485        let cat = self.catalog.read();
12486        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
12487        for (table_index, entry) in cat.tables.iter().enumerate() {
12488            if table_index % 256 == 0 {
12489                if let Some(control) = control {
12490                    control.checkpoint()?;
12491                }
12492            }
12493            if !matches!(entry.state, TableState::Live) {
12494                continue;
12495            }
12496            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
12497            let mut err = |sev: &str, desc: String| {
12498                issues.push(CheckIssue {
12499                    table_id: entry.table_id,
12500                    table_name: entry.name.clone(),
12501                    severity: sev.into(),
12502                    description: desc,
12503                });
12504            };
12505            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
12506                Ok(m) => m,
12507                Err(e) => {
12508                    err("error", format!("manifest read failed: {e}"));
12509                    continue;
12510                }
12511            };
12512            if m.flushed_epoch > m.current_epoch {
12513                err(
12514                    "error",
12515                    format!(
12516                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
12517                        m.flushed_epoch, m.current_epoch
12518                    ),
12519                );
12520            }
12521
12522            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
12523            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
12524            for (run_index, rr) in m.runs.iter().enumerate() {
12525                if run_index % 256 == 0 {
12526                    if let Some(control) = control {
12527                        control.checkpoint()?;
12528                    }
12529                }
12530                referenced.insert(rr.run_id);
12531                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
12532                if !run_path.exists() {
12533                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
12534                    continue;
12535                }
12536                match crate::sorted_run::RunReader::open(
12537                    &run_path,
12538                    entry.schema.clone(),
12539                    self.kek.clone(),
12540                ) {
12541                    Ok(reader) => {
12542                        if reader.row_count() as u64 != rr.row_count {
12543                            err(
12544                                "error",
12545                                format!(
12546                                    "run r-{} row count mismatch: manifest {} vs run {}",
12547                                    rr.run_id,
12548                                    rr.row_count,
12549                                    reader.row_count()
12550                                ),
12551                            );
12552                        }
12553                    }
12554                    Err(e) => {
12555                        err(
12556                            "error",
12557                            format!("run r-{} integrity check failed: {e}", rr.run_id),
12558                        );
12559                    }
12560                }
12561            }
12562
12563            // Compaction-superseded runs awaiting retention-gated deletion are
12564            // tracked in `retiring`; their files are expected on disk, so they
12565            // are not orphans.
12566            for r in &m.retiring {
12567                referenced.insert(r.run_id);
12568            }
12569
12570            // Orphan `.sr` files present on disk but absent from the manifest.
12571            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
12572                for (entry_index, ent) in rd.flatten().enumerate() {
12573                    if entry_index % 256 == 0 {
12574                        if let Some(control) = control {
12575                            control.checkpoint()?;
12576                        }
12577                    }
12578                    let p = ent.path();
12579                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
12580                        continue;
12581                    }
12582                    let run_id = p
12583                        .file_stem()
12584                        .and_then(|s| s.to_str())
12585                        .and_then(|s| s.strip_prefix("r-"))
12586                        .and_then(|s| s.parse::<u128>().ok());
12587                    if let Some(id) = run_id {
12588                        if !referenced.contains(&id) {
12589                            err(
12590                                "warning",
12591                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
12592                            );
12593                        }
12594                    }
12595                }
12596            }
12597        }
12598
12599        let external_names = cat
12600            .external_tables
12601            .iter()
12602            .map(|entry| entry.name.clone())
12603            .collect::<std::collections::HashSet<_>>();
12604        let vtab_dir = self.root.join(VTAB_DIR);
12605        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
12606            for (entry_index, entry) in entries.flatten().enumerate() {
12607                if entry_index % 256 == 0 {
12608                    if let Some(control) = control {
12609                        control.checkpoint()?;
12610                    }
12611                }
12612                let name = entry.file_name();
12613                let Some(name) = name.to_str() else { continue };
12614                if !external_names.contains(name) {
12615                    issues.push(CheckIssue {
12616                        table_id: EXTERNAL_TABLE_ID,
12617                        table_name: name.to_string(),
12618                        severity: "warning".into(),
12619                        description: format!(
12620                            "orphan external table state entry {:?} not referenced by the catalog",
12621                            entry.path()
12622                        ),
12623                    });
12624                }
12625            }
12626        }
12627
12628        // WAL retention / integrity invariant (spec §16): every on-disk WAL
12629        // segment must open (header magic + version, and the frame cipher must
12630        // be derivable for an encrypted WAL). A segment that won't open is
12631        // corrupt or truncated and would break crash recovery. `table_id` is
12632        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
12633        // never confuses a WAL issue with a real table.
12634        if let Some(control) = control {
12635            control.checkpoint()?;
12636        }
12637        for (seg, msg) in self.shared_wal.lock().verify_segments() {
12638            issues.push(CheckIssue {
12639                table_id: WAL_TABLE_ID,
12640                table_name: "<wal>".into(),
12641                severity: "error".into(),
12642                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
12643            });
12644        }
12645        Ok(issues)
12646    }
12647
12648    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
12649    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
12650    /// unmounts them from the live table map so the DB still opens.
12651    pub fn doctor(&self) -> Result<Vec<u64>> {
12652        let control = crate::ExecutionControl::new(None);
12653        self.doctor_controlled(&control, || true)
12654    }
12655
12656    /// Check cancellably, then fence immediately before the first quarantine
12657    /// mutation. Returning `false` from `before_publish` leaves the database
12658    /// untouched.
12659    #[doc(hidden)]
12660    pub fn doctor_controlled<F>(
12661        &self,
12662        control: &crate::ExecutionControl,
12663        before_publish: F,
12664    ) -> Result<Vec<u64>>
12665    where
12666        F: FnOnce() -> bool,
12667    {
12668        self.doctor_controlled_with_receipt(control, before_publish)
12669            .map(|(quarantined, _)| quarantined)
12670    }
12671
12672    /// Check cancellably and return the exact catalog epoch used for a
12673    /// quarantine publication. No receipt is returned when nothing changes.
12674    #[doc(hidden)]
12675    pub fn doctor_controlled_with_receipt<F>(
12676        &self,
12677        control: &crate::ExecutionControl,
12678        before_publish: F,
12679    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
12680    where
12681        F: FnOnce() -> bool,
12682    {
12683        // Hold the DDL lock for the whole operation to prevent concurrent
12684        // create_table/drop_table from racing the catalog/dir mutation.
12685        let _ddl = self.ddl_lock.lock();
12686        let _security_write = self.security_write()?;
12687        let issues = self.check_inner(Some(control))?;
12688        // A corrupt WAL segment is reported as an error but is NOT a table
12689        // problem — quarantining an innocent table cannot fix it (and the first
12690        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
12691        // them disjoint). The admin must address WAL corruption manually.
12692        let bad_tables: std::collections::HashSet<u64> = issues
12693            .iter()
12694            .filter(|i| {
12695                i.severity == "error"
12696                    && i.table_id != WAL_TABLE_ID
12697                    && i.table_id != EXTERNAL_TABLE_ID
12698            })
12699            .map(|i| i.table_id)
12700            .collect();
12701        if bad_tables.is_empty() {
12702            return Ok((Vec::new(), None));
12703        }
12704        let _commit = self.commit_lock.lock();
12705        control.checkpoint()?;
12706        if !before_publish() {
12707            return Err(MongrelError::Cancelled);
12708        }
12709        let maintenance_epoch = self.epoch.bump_assigned();
12710        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
12711
12712        let qdir = self.root.join("_quarantine");
12713        crate::durable_file::create_directory(&qdir)?;
12714        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
12715        bad_tables.sort_unstable();
12716
12717        // Quiesce every mounted target before catalog publication. Existing
12718        // handle clones are marked unavailable in the publication callback so
12719        // they cannot append to the shared WAL after their catalog entry drops.
12720        let mut handles = self
12721            .tables
12722            .read()
12723            .iter()
12724            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
12725            .map(|(table_id, handle)| (*table_id, handle.clone()))
12726            .collect::<Vec<_>>();
12727        handles.sort_by_key(|(table_id, _)| *table_id);
12728        let mut table_guards = handles
12729            .iter()
12730            .map(|(table_id, handle)| (*table_id, handle.lock()))
12731            .collect::<Vec<_>>();
12732
12733        let mut next_catalog = self.catalog.read().clone();
12734        for table_id in &bad_tables {
12735            if let Some(entry) = next_catalog
12736                .tables
12737                .iter_mut()
12738                .find(|entry| entry.table_id == *table_id)
12739            {
12740                entry.state = TableState::Dropped {
12741                    at_epoch: maintenance_epoch.0,
12742                };
12743            }
12744        }
12745        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
12746
12747        let txn_id = self.alloc_txn_id()?;
12748        let commit_seq = {
12749            let mut wal = self.shared_wal.lock();
12750            let append: Result<u64> = (|| {
12751                for table_id in &bad_tables {
12752                    wal.append(
12753                        txn_id,
12754                        *table_id,
12755                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
12756                            table_id: *table_id,
12757                        }),
12758                    )?;
12759                }
12760                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12761                wal.append_commit(txn_id, maintenance_epoch, &[])
12762            })();
12763            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
12764        };
12765        self.await_durable_commit(commit_seq, maintenance_epoch)?;
12766        for (_, table) in &mut table_guards {
12767            table.mark_unavailable_after_quarantine();
12768        }
12769        {
12770            let mut live_tables = self.tables.write();
12771            for table_id in &bad_tables {
12772                live_tables.remove(table_id);
12773            }
12774        }
12775        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
12776        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, checkpoint)?;
12777
12778        // The catalog drop is durable. Directory placement is secondary but
12779        // still uses a write-through rename. A failure reports the known
12780        // catalog outcome and leaves a harmless orphan under `tables/`.
12781        for table_id in &bad_tables {
12782            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
12783            if source.exists() {
12784                let destination = qdir.join(table_id.to_string());
12785                if let Err(error) = crate::durable_file::rename(&source, &destination) {
12786                    return Err(MongrelError::DurableCommit {
12787                        epoch: maintenance_epoch.0,
12788                        message: format!(
12789                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
12790                        ),
12791                    });
12792                }
12793            }
12794        }
12795        Ok((
12796            bad_tables,
12797            Some(MaintenanceReceipt {
12798                epoch: maintenance_epoch,
12799            }),
12800        ))
12801    }
12802
12803    /// The DB-wide KEK (if encrypted).
12804    #[allow(dead_code)]
12805    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
12806        self.kek.as_ref()
12807    }
12808
12809    /// Shared epoch authority (used by the transaction layer in P2).
12810    #[allow(dead_code)]
12811    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
12812        &self.epoch
12813    }
12814
12815    /// Shared snapshot registry (used by GC in P3.6).
12816    #[allow(dead_code)]
12817    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
12818        &self.snapshots
12819    }
12820}
12821
12822fn external_state_dir(root: &Path, name: &str) -> PathBuf {
12823    root.join(VTAB_DIR).join(name)
12824}
12825
12826fn append_catalog_snapshot(
12827    wal: &mut crate::wal::SharedWal,
12828    txn_id: u64,
12829    catalog: &Catalog,
12830) -> Result<()> {
12831    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
12832    wal.append(
12833        txn_id,
12834        WAL_TABLE_ID,
12835        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
12836    )?;
12837    Ok(())
12838}
12839
12840fn filter_ignored_staging(
12841    staging: Vec<(u64, crate::txn::Staged)>,
12842    ignored_indices: &std::collections::BTreeSet<usize>,
12843) -> Vec<(u64, crate::txn::Staged)> {
12844    if ignored_indices.is_empty() {
12845        return staging;
12846    }
12847    staging
12848        .into_iter()
12849        .enumerate()
12850        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
12851        .collect()
12852}
12853
12854fn external_state_file(root: &Path, name: &str) -> PathBuf {
12855    external_state_dir(root, name).join("state.json")
12856}
12857
12858fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
12859    let path = external_state_file(root, name);
12860    match std::fs::read(path) {
12861        Ok(bytes) => Ok(bytes),
12862        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
12863        Err(e) => Err(e.into()),
12864    }
12865}
12866
12867fn current_external_state_bytes(
12868    root: &Path,
12869    external_states: &[(String, Vec<u8>)],
12870    name: &str,
12871) -> Result<Vec<u8>> {
12872    for (table, state) in external_states.iter().rev() {
12873        if table == name {
12874            return Ok(state.clone());
12875        }
12876    }
12877    read_external_state_file(root, name)
12878}
12879
12880fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
12881    let mut out = external_states;
12882    dedup_external_states_in_place(&mut out);
12883    out
12884}
12885
12886fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
12887    let mut seen = std::collections::HashSet::new();
12888    let mut out = Vec::with_capacity(external_states.len());
12889    for (name, state) in std::mem::take(external_states).into_iter().rev() {
12890        if seen.insert(name.clone()) {
12891            out.push((name, state));
12892        }
12893    }
12894    out.reverse();
12895    *external_states = out;
12896}
12897
12898fn prepare_external_state_file(
12899    root: &Path,
12900    name: &str,
12901    state: &[u8],
12902    txn_id: u64,
12903) -> Result<PathBuf> {
12904    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
12905    let dir = external_state_dir(root, name);
12906    crate::durable_file::create_directory(&dir)?;
12907    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
12908    {
12909        let mut file = std::fs::OpenOptions::new()
12910            .create_new(true)
12911            .write(true)
12912            .open(&pending)?;
12913        file.write_all(state)?;
12914        file.sync_all()?;
12915    }
12916    Ok(pending)
12917}
12918
12919fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
12920    let path = external_state_file(root, name);
12921    crate::durable_file::replace(pending, &path)?;
12922    Ok(())
12923}
12924
12925fn write_external_state_file(
12926    durable: &crate::durable_file::DurableRoot,
12927    name: &str,
12928    state: &[u8],
12929) -> Result<()> {
12930    let directory = Path::new(VTAB_DIR).join(name);
12931    durable.create_directory_all(&directory)?;
12932    durable.write_atomic(directory.join("state.json"), state)?;
12933    Ok(())
12934}
12935
12936fn validate_recovered_data_table(
12937    catalog: &Catalog,
12938    tables: &HashMap<u64, TableHandle>,
12939    table_id: u64,
12940    commit_epoch: u64,
12941    offset: u64,
12942) -> Result<bool> {
12943    let entry = catalog
12944        .tables
12945        .iter()
12946        .find(|entry| entry.table_id == table_id)
12947        .ok_or_else(|| MongrelError::CorruptWal {
12948            offset,
12949            reason: format!("committed record references unknown table {table_id}"),
12950        })?;
12951    if commit_epoch < entry.created_epoch {
12952        return Err(MongrelError::CorruptWal {
12953            offset,
12954            reason: format!(
12955                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12956                entry.created_epoch
12957            ),
12958        });
12959    }
12960    match entry.state {
12961        TableState::Dropped { at_epoch } => {
12962            // Abandoned hidden builds are marked dropped at the last durable
12963            // boundary during open, so their final build commit may equal the
12964            // cleanup epoch. Ordinary table drops consume a new epoch and must
12965            // remain strictly later than every data commit.
12966            let abandoned_build_boundary =
12967                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12968            if commit_epoch >= at_epoch && !abandoned_build_boundary {
12969                Err(MongrelError::CorruptWal {
12970                    offset,
12971                    reason: format!(
12972                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
12973                    ),
12974                })
12975            } else {
12976                Ok(false)
12977            }
12978        }
12979        TableState::Live | TableState::Building { .. } => {
12980            if tables.contains_key(&table_id) {
12981                Ok(true)
12982            } else {
12983                Err(MongrelError::CorruptWal {
12984                    offset,
12985                    reason: format!("live table {table_id} has no mounted recovery handle"),
12986                })
12987            }
12988        }
12989    }
12990}
12991
12992type RecoveryTableStage = (
12993    Vec<crate::memtable::Row>,
12994    Vec<(crate::rowid::RowId, Epoch)>,
12995    Option<Epoch>,
12996    Epoch,
12997);
12998
12999#[derive(Clone)]
13000struct RecoveryValidationTable {
13001    schema: Schema,
13002    flushed_epoch: u64,
13003}
13004
13005fn validate_shared_wal_recovery_plan(
13006    durable_root: &crate::durable_file::DurableRoot,
13007    catalog: &Catalog,
13008    recovered_table_ids: &HashSet<u64>,
13009    reconciled_table_ids: &HashSet<u64>,
13010    meta_dek: Option<&[u8; META_DEK_LEN]>,
13011    kek: Option<Arc<crate::encryption::Kek>>,
13012    records: &[crate::wal::Record],
13013) -> Result<()> {
13014    use crate::wal::{DdlOp, Op};
13015
13016    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
13017    for entry in &catalog.tables {
13018        if !matches!(entry.state, TableState::Live) {
13019            continue;
13020        }
13021        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
13022        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
13023            Ok(manifest) => Some(manifest),
13024            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
13025            Err(error) => return Err(error),
13026        };
13027        let flushed_epoch = if let Some(manifest) = manifest {
13028            if manifest.table_id != entry.table_id {
13029                return Err(MongrelError::Conflict(format!(
13030                    "catalog table {} storage identity mismatch",
13031                    entry.table_id
13032                )));
13033            }
13034            if (manifest.schema_id != entry.schema.schema_id
13035                && !reconciled_table_ids.contains(&entry.table_id))
13036                || manifest.flushed_epoch > manifest.current_epoch
13037                || manifest.global_idx_epoch > manifest.current_epoch
13038                || manifest.next_row_id == u64::MAX
13039                || manifest.auto_inc_next < 0
13040                || manifest.auto_inc_next == i64::MAX
13041                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
13042            {
13043                return Err(MongrelError::InvalidArgument(format!(
13044                    "table {} manifest counters or schema identity are invalid",
13045                    entry.table_id
13046                )));
13047            }
13048            #[cfg(feature = "encryption")]
13049            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
13050            #[cfg(not(feature = "encryption"))]
13051            let idx_dek: Option<zeroize::Zeroizing<[u8; 32]>> = None;
13052            crate::global_idx::read_durable_for(
13053                durable_root,
13054                &relative_dir,
13055                entry.table_id,
13056                &entry.schema,
13057                idx_dek.as_deref(),
13058            )?;
13059            let mut run_ids = HashSet::new();
13060            let mut maximum_row_id = None::<u64>;
13061            for run in &manifest.runs {
13062                if run.run_id >= u64::MAX as u128
13063                    || run.epoch_created > manifest.current_epoch
13064                    || !run_ids.insert(run.run_id)
13065                {
13066                    return Err(MongrelError::InvalidArgument(format!(
13067                        "table {} manifest contains an invalid or duplicate run id",
13068                        entry.table_id
13069                    )));
13070                }
13071                let relative = relative_dir
13072                    .join(crate::engine::RUNS_DIR)
13073                    .join(format!("r-{}.sr", run.run_id as u64));
13074                let file = durable_root.open_regular(&relative)?;
13075                let mut reader = crate::sorted_run::RunReader::open_file(
13076                    file,
13077                    entry.schema.clone(),
13078                    kek.clone(),
13079                )?;
13080                let header = reader.header();
13081                if header.run_id != run.run_id
13082                    || header.level != run.level
13083                    || header.row_count != run.row_count
13084                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
13085                    || header.is_uniform_epoch() && header.epoch_created != 0
13086                    || header.schema_id > entry.schema.schema_id
13087                {
13088                    return Err(MongrelError::InvalidArgument(format!(
13089                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
13090                        entry.table_id,
13091                        run.run_id,
13092                        header.run_id,
13093                        header.level,
13094                        header.row_count,
13095                        header.epoch_created,
13096                        header.schema_id,
13097                        run.run_id,
13098                        run.level,
13099                        run.row_count,
13100                        run.epoch_created,
13101                        entry.schema.schema_id,
13102                    )));
13103                }
13104                if header.row_count != 0 {
13105                    maximum_row_id = Some(
13106                        maximum_row_id
13107                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
13108                    );
13109                }
13110                reader.validate_all_pages()?;
13111            }
13112            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
13113                return Err(MongrelError::InvalidArgument(format!(
13114                    "table {} next_row_id does not advance beyond persisted rows",
13115                    entry.table_id
13116                )));
13117            }
13118            for run in &manifest.retiring {
13119                if run.run_id >= u64::MAX as u128
13120                    || run.retire_epoch > manifest.current_epoch
13121                    || !run_ids.insert(run.run_id)
13122                {
13123                    return Err(MongrelError::InvalidArgument(format!(
13124                        "table {} manifest contains an invalid or aliased retired run",
13125                        entry.table_id
13126                    )));
13127                }
13128            }
13129            manifest.flushed_epoch
13130        } else {
13131            if !recovered_table_ids.contains(&entry.table_id) {
13132                return Err(MongrelError::NotFound(format!(
13133                    "live table {} manifest is missing",
13134                    entry.table_id
13135                )));
13136            }
13137            0
13138        };
13139        tables.insert(
13140            entry.table_id,
13141            RecoveryValidationTable {
13142                schema: entry.schema.clone(),
13143                flushed_epoch,
13144            },
13145        );
13146    }
13147
13148    let committed = records
13149        .iter()
13150        .filter_map(|record| match record.op {
13151            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
13152            _ => None,
13153        })
13154        .collect::<HashMap<_, _>>();
13155    let mut run_ids = HashSet::new();
13156    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
13157    for record in records {
13158        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
13159            continue;
13160        };
13161        match &record.op {
13162            Op::Put { table_id, rows } => {
13163                let table = validate_recovery_data_table_plan(
13164                    catalog,
13165                    &tables,
13166                    *table_id,
13167                    commit_epoch,
13168                    record.seq.0,
13169                )?;
13170                let decoded: Vec<crate::memtable::Row> =
13171                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
13172                        offset: record.seq.0,
13173                        reason: format!(
13174                            "committed Put payload for transaction {} could not be decoded: {error}",
13175                            record.txn_id
13176                        ),
13177                    })?;
13178                if let Some(table) = table {
13179                    for row in &decoded {
13180                        if !recovered_row_ids
13181                            .entry(*table_id)
13182                            .or_default()
13183                            .insert(row.row_id.0)
13184                        {
13185                            return Err(MongrelError::CorruptWal {
13186                                offset: record.seq.0,
13187                                reason: format!(
13188                                    "committed WAL repeats recovered row id {} for table {table_id}",
13189                                    row.row_id.0
13190                                ),
13191                            });
13192                        }
13193                        validate_recovered_row(&table.schema, row)?;
13194                    }
13195                }
13196            }
13197            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
13198                validate_recovery_data_table_plan(
13199                    catalog,
13200                    &tables,
13201                    *table_id,
13202                    commit_epoch,
13203                    record.seq.0,
13204                )?;
13205            }
13206            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
13207            Op::Ddl(DdlOp::ResetExternalTableState {
13208                name,
13209                generation_epoch,
13210            }) => {
13211                if *generation_epoch != commit_epoch {
13212                    return Err(MongrelError::CorruptWal {
13213                        offset: record.seq.0,
13214                        reason: format!(
13215                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
13216                        ),
13217                    });
13218                }
13219                validate_recovered_external_name(name)?;
13220            }
13221            Op::TxnCommit { added_runs, .. } => {
13222                for added in added_runs {
13223                    let Some(table) = validate_recovery_data_table_plan(
13224                        catalog,
13225                        &tables,
13226                        added.table_id,
13227                        commit_epoch,
13228                        record.seq.0,
13229                    )?
13230                    else {
13231                        continue;
13232                    };
13233                    if added.run_id >= u64::MAX as u128
13234                        || !run_ids.insert((added.table_id, added.run_id))
13235                    {
13236                        return Err(MongrelError::CorruptWal {
13237                            offset: record.seq.0,
13238                            reason: format!(
13239                                "duplicate or invalid recovered run {} for table {}",
13240                                added.run_id, added.table_id
13241                            ),
13242                        });
13243                    }
13244                    if commit_epoch <= table.flushed_epoch {
13245                        continue;
13246                    }
13247                    validate_planned_spilled_run(
13248                        durable_root,
13249                        record.txn_id,
13250                        commit_epoch,
13251                        added,
13252                        &table.schema,
13253                        kek.clone(),
13254                    )?;
13255                }
13256            }
13257            _ => {}
13258        }
13259    }
13260    Ok(())
13261}
13262
13263fn validate_recovery_data_table_plan<'a>(
13264    catalog: &Catalog,
13265    tables: &'a HashMap<u64, RecoveryValidationTable>,
13266    table_id: u64,
13267    commit_epoch: u64,
13268    offset: u64,
13269) -> Result<Option<&'a RecoveryValidationTable>> {
13270    let entry = catalog
13271        .tables
13272        .iter()
13273        .find(|entry| entry.table_id == table_id)
13274        .ok_or_else(|| MongrelError::CorruptWal {
13275            offset,
13276            reason: format!("committed record references unknown table {table_id}"),
13277        })?;
13278    if commit_epoch < entry.created_epoch {
13279        return Err(MongrelError::CorruptWal {
13280            offset,
13281            reason: format!(
13282                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
13283                entry.created_epoch
13284            ),
13285        });
13286    }
13287    match entry.state {
13288        TableState::Dropped { at_epoch } => {
13289            let abandoned =
13290                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
13291            if commit_epoch >= at_epoch && !abandoned {
13292                return Err(MongrelError::CorruptWal {
13293                    offset,
13294                    reason: format!(
13295                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
13296                    ),
13297                });
13298            }
13299            Ok(None)
13300        }
13301        TableState::Live => {
13302            tables
13303                .get(&table_id)
13304                .map(Some)
13305                .ok_or_else(|| MongrelError::CorruptWal {
13306                    offset,
13307                    reason: format!("live table {table_id} has no recovery plan"),
13308                })
13309        }
13310        TableState::Building { .. } => Err(MongrelError::CorruptWal {
13311            offset,
13312            reason: format!("building table {table_id} was not normalized before recovery"),
13313        }),
13314    }
13315}
13316
13317fn validate_planned_spilled_run(
13318    root: &crate::durable_file::DurableRoot,
13319    txn_id: u64,
13320    commit_epoch: u64,
13321    added: &crate::wal::AddedRun,
13322    schema: &Schema,
13323    kek: Option<Arc<crate::encryption::Kek>>,
13324) -> Result<()> {
13325    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
13326    let destination = table
13327        .join(crate::engine::RUNS_DIR)
13328        .join(format!("r-{}.sr", added.run_id as u64));
13329    let pending = table
13330        .join("_txn")
13331        .join(txn_id.to_string())
13332        .join(format!("r-{}.sr", added.run_id as u64));
13333    let file = match root.open_regular(&destination) {
13334        Ok(file) => file,
13335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13336            root.open_regular(&pending).map_err(|pending_error| {
13337                if pending_error.kind() == std::io::ErrorKind::NotFound {
13338                    MongrelError::CorruptWal {
13339                        offset: commit_epoch,
13340                        reason: format!(
13341                            "committed spilled run {} for transaction {txn_id} is missing",
13342                            added.run_id
13343                        ),
13344                    }
13345                } else {
13346                    pending_error.into()
13347                }
13348            })?
13349        }
13350        Err(error) => return Err(error.into()),
13351    };
13352    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
13353    let header = reader.header();
13354    if header.run_id != added.run_id
13355        || header.content_hash != added.content_hash
13356        || header.row_count != added.row_count
13357        || header.level != added.level
13358        || header.min_row_id != added.min_row_id
13359        || header.max_row_id != added.max_row_id
13360        || header.schema_id != schema.schema_id
13361        || !header.is_uniform_epoch()
13362        || header.epoch_created != 0
13363    {
13364        return Err(MongrelError::CorruptWal {
13365            offset: commit_epoch,
13366            reason: format!(
13367                "committed spilled run {} metadata differs from WAL",
13368                added.run_id
13369            ),
13370        });
13371    }
13372    reader.validate_all_pages()?;
13373    Ok(())
13374}
13375
13376/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
13377///
13378/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
13379/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
13380/// 2 applies each committed data record (Put/Delete) to its table at the commit
13381/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
13382/// durable in a sorted run). Finally the shared epoch authority is raised to the
13383/// max committed epoch so the next commit continues monotonically.
13384fn recover_shared_wal(
13385    durable_root: &crate::durable_file::DurableRoot,
13386    tables: &HashMap<u64, TableHandle>,
13387    catalog: &Catalog,
13388    epoch: &EpochAuthority,
13389    records: &[crate::wal::Record],
13390) -> Result<()> {
13391    use crate::memtable::Row;
13392    use crate::wal::{DdlOp, Op};
13393
13394    // Pass 1: committed-txn outcomes + collect spilled-run info.
13395    let mut committed: HashMap<u64, u64> = HashMap::new();
13396    let mut spilled_to_link: Vec<(
13397        u64, /*txn_id*/
13398        u64, /*epoch*/
13399        Vec<crate::wal::AddedRun>,
13400    )> = Vec::new();
13401    for r in records {
13402        if let Op::TxnCommit {
13403            epoch: ce,
13404            ref added_runs,
13405        } = r.op
13406        {
13407            committed.insert(r.txn_id, ce);
13408            if !added_runs.is_empty() {
13409                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
13410            }
13411        }
13412    }
13413    for record in records {
13414        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
13415            continue;
13416        };
13417        match &record.op {
13418            Op::Put { table_id, .. }
13419            | Op::Delete { table_id, .. }
13420            | Op::TruncateTable { table_id } => {
13421                validate_recovered_data_table(
13422                    catalog,
13423                    tables,
13424                    *table_id,
13425                    commit_epoch,
13426                    record.seq.0,
13427                )?;
13428            }
13429            Op::TxnCommit { added_runs, .. } => {
13430                for run in added_runs {
13431                    validate_recovered_data_table(
13432                        catalog,
13433                        tables,
13434                        run.table_id,
13435                        commit_epoch,
13436                        record.seq.0,
13437                    )?;
13438                }
13439            }
13440            _ => {}
13441        }
13442    }
13443    let truncated_transactions: HashSet<(u64, u64)> = records
13444        .iter()
13445        .filter_map(|record| {
13446            committed.get(&record.txn_id)?;
13447            match record.op {
13448                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
13449                _ => None,
13450            }
13451        })
13452        .collect();
13453
13454    // Pass 2: stage data per table, gated by flushed_epoch.
13455    enum ExternalRecoveryAction {
13456        Write { name: String, state: Vec<u8> },
13457        Reset { name: String },
13458    }
13459    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
13460    let mut external_actions = Vec::new();
13461    let mut max_epoch = epoch.visible().0;
13462    for r in records.iter().cloned() {
13463        let Some(&ce) = committed.get(&r.txn_id) else {
13464            continue; // aborted / in-flight — discard
13465        };
13466        let commit_epoch = Epoch(ce);
13467        max_epoch = max_epoch.max(ce);
13468        match r.op {
13469            Op::Put { table_id, rows } => {
13470                // Skip if this table already flushed past the commit epoch.
13471                let skip = tables
13472                    .get(&table_id)
13473                    .map(|h| h.lock().flushed_epoch() >= ce)
13474                    .unwrap_or(true);
13475                if skip {
13476                    continue;
13477                }
13478                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
13479                    MongrelError::CorruptWal {
13480                        offset: r.seq.0,
13481                        reason: format!(
13482                            "committed Put payload for transaction {} could not be decoded: {error}",
13483                            r.txn_id
13484                        ),
13485                    }
13486                })?;
13487                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
13488                // at pending_epoch which equals the commit epoch, but be robust).
13489                let rows: Vec<Row> = rows
13490                    .into_iter()
13491                    .map(|mut row| {
13492                        row.committed_epoch = commit_epoch;
13493                        row
13494                    })
13495                    .collect();
13496                let entry = stage
13497                    .entry(table_id)
13498                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13499                entry.0.extend(rows);
13500                entry.3 = commit_epoch;
13501            }
13502            Op::Delete { table_id, row_ids } => {
13503                let skip = tables
13504                    .get(&table_id)
13505                    .map(|h| h.lock().flushed_epoch() >= ce)
13506                    .unwrap_or(true);
13507                if skip {
13508                    continue;
13509                }
13510                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
13511                let entry = stage
13512                    .entry(table_id)
13513                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13514                entry.1.extend(dels);
13515                entry.3 = commit_epoch;
13516            }
13517            Op::TruncateTable { table_id } => {
13518                let skip = tables
13519                    .get(&table_id)
13520                    .map(|h| h.lock().flushed_epoch() >= ce)
13521                    .unwrap_or(true);
13522                if skip {
13523                    continue;
13524                }
13525                stage.insert(
13526                    table_id,
13527                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
13528                );
13529            }
13530            Op::ExternalTableState { name, state } => {
13531                let current_generation = catalog
13532                    .external_tables
13533                    .iter()
13534                    .find(|entry| entry.name == name)
13535                    .map(|entry| entry.created_epoch);
13536                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
13537                    validate_recovered_external_name(&name)?;
13538                    external_actions.push(ExternalRecoveryAction::Write { name, state });
13539                }
13540            }
13541            Op::Ddl(DdlOp::ResetExternalTableState {
13542                name,
13543                generation_epoch,
13544            }) => {
13545                if generation_epoch != ce {
13546                    return Err(MongrelError::CorruptWal {
13547                        offset: r.seq.0,
13548                        reason: format!(
13549                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
13550                    ),
13551                    });
13552                }
13553                validate_recovered_external_name(&name)?;
13554                external_actions.push(ExternalRecoveryAction::Reset { name });
13555            }
13556            Op::Flush { .. }
13557            | Op::TxnCommit { .. }
13558            | Op::TxnAbort
13559            | Op::Ddl(_)
13560            | Op::BeforeImage { .. }
13561            | Op::CommitTimestamp { .. }
13562            | Op::SpilledRows { .. } => {}
13563        }
13564    }
13565    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
13566        added_runs.retain(|added| {
13567            tables
13568                .get(&added.table_id)
13569                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
13570        });
13571    }
13572    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
13573    validate_recovery_table_stages(tables, &stage)?;
13574    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
13575
13576    // All WAL payloads, catalog generations, table stages, and immutable run
13577    // identities have now been validated. Only this application phase mutates
13578    // the database tree.
13579    for action in external_actions {
13580        match action {
13581            ExternalRecoveryAction::Write { name, state } => {
13582                write_external_state_file(durable_root, &name, &state)?;
13583            }
13584            ExternalRecoveryAction::Reset { name } => {
13585                durable_root.create_directory_all(VTAB_DIR)?;
13586                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
13587            }
13588        }
13589    }
13590    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
13591        let Some(handle) = tables.get(&table_id) else {
13592            continue;
13593        };
13594        let mut t = handle.lock();
13595        if let Some(epoch) = truncate_epoch {
13596            t.apply_truncate(epoch);
13597        }
13598        t.recover_apply(rows, deletes)?;
13599        // The WAL can be newer than the copied/persisted manifest after a
13600        // crash or replication apply. Rebuild O(1) count metadata from the
13601        // recovered state before endorsing the commit epoch in the manifest.
13602        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13603        t.live_count = rows.len() as u64;
13604        // Recovery can replay older row commits while a newer spilled run is
13605        // already linked by the copied manifest. Never move that manifest's
13606        // epoch behind its existing run references.
13607        t.persist_manifest(table_epoch.max(epoch.visible()))?;
13608    }
13609
13610    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
13611    // between TxnCommit sync and the publish phase leaves the run in
13612    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
13613    for (txn_id, ce, added_runs) in &spilled_to_link {
13614        for ar in added_runs {
13615            let Some(handle) = tables.get(&ar.table_id) else {
13616                continue;
13617            };
13618            let mut t = handle.lock();
13619            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
13620            let destination = table_dir
13621                .join(crate::engine::RUNS_DIR)
13622                .join(format!("r-{}.sr", ar.run_id));
13623            match durable_root.open_regular(&destination) {
13624                Ok(_) => {}
13625                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13626                    let pending = table_dir
13627                        .join("_txn")
13628                        .join(txn_id.to_string())
13629                        .join(format!("r-{}.sr", ar.run_id));
13630                    durable_root.rename_file_new(&pending, &destination)?;
13631                }
13632                Err(error) => return Err(error.into()),
13633            }
13634            // Only link a run whose file is actually present, and never re-link
13635            // one the publish phase already persisted into the manifest (which is
13636            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
13637            // until segment GC). `recover_spilled_run` is idempotent + reconciles
13638            // `live_count`/indexes only when the run is genuinely new.
13639            let linked = t.recover_spilled_run(crate::manifest::RunRef {
13640                run_id: ar.run_id,
13641                level: ar.level,
13642                epoch_created: *ce,
13643                row_count: ar.row_count,
13644            });
13645            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
13646            if replaced {
13647                t.set_flushed_epoch(Epoch(*ce));
13648            }
13649            if linked || replaced {
13650                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
13651            }
13652        }
13653    }
13654
13655    epoch.advance_recovered(Epoch(max_epoch));
13656    Ok(())
13657}
13658
13659fn reconcile_recovered_table_metadata(
13660    tables: &HashMap<u64, TableHandle>,
13661    epoch: Epoch,
13662) -> Result<()> {
13663    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
13664    table_ids.sort_unstable();
13665    let mut plans = Vec::with_capacity(table_ids.len());
13666    for table_id in &table_ids {
13667        let handle = tables.get(table_id).ok_or_else(|| {
13668            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13669        })?;
13670        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
13671    }
13672    // Every table's data and metadata have been decoded successfully. Publish
13673    // repairs only after the complete database-wide plan is known valid.
13674    for (table_id, plan) in plans {
13675        let handle = tables.get(&table_id).ok_or_else(|| {
13676            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13677        })?;
13678        handle.lock().apply_recovered_metadata(plan, epoch)?;
13679    }
13680    Ok(())
13681}
13682
13683fn validate_recovered_external_name(name: &str) -> Result<()> {
13684    if name.is_empty()
13685        || !name.chars().all(|character| {
13686            character.is_ascii_alphanumeric() || character == '_' || character == '-'
13687        })
13688    {
13689        return Err(MongrelError::CorruptWal {
13690            offset: 0,
13691            reason: format!("unsafe recovered external-table name {name:?}"),
13692        });
13693    }
13694    Ok(())
13695}
13696
13697fn validate_recovery_table_stages(
13698    tables: &HashMap<u64, TableHandle>,
13699    stages: &HashMap<u64, RecoveryTableStage>,
13700) -> Result<()> {
13701    for (table_id, (rows, _, _, _)) in stages {
13702        let handle = tables
13703            .get(table_id)
13704            .ok_or_else(|| MongrelError::CorruptWal {
13705                offset: *table_id,
13706                reason: format!("recovery stage references unmounted table {table_id}"),
13707            })?;
13708        let table = handle.lock();
13709        // Force all existing immutable runs through their integrity/decode path
13710        // before any other table manifest can be changed.
13711        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13712        for row in rows {
13713            validate_recovered_row(table.schema(), row)?;
13714        }
13715    }
13716    Ok(())
13717}
13718
13719fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
13720    if row.deleted || row.row_id.0 == u64::MAX {
13721        return Err(MongrelError::CorruptWal {
13722            offset: row.row_id.0,
13723            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
13724        });
13725    }
13726    let cells = row
13727        .columns
13728        .iter()
13729        .map(|(column, value)| (*column, value.clone()))
13730        .collect::<Vec<_>>();
13731    schema
13732        .validate_persisted_values(&cells)
13733        .map_err(|error| MongrelError::CorruptWal {
13734            offset: row.row_id.0,
13735            reason: format!("recovered row violates table schema: {error}"),
13736        })?;
13737    if schema.auto_increment_column().is_some_and(|column| {
13738        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
13739    }) {
13740        return Err(MongrelError::CorruptWal {
13741            offset: row.row_id.0,
13742            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
13743        });
13744    }
13745    Ok(())
13746}
13747
13748fn validate_recovery_spilled_runs(
13749    root: &crate::durable_file::DurableRoot,
13750    tables: &HashMap<u64, TableHandle>,
13751    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
13752) -> Result<()> {
13753    let mut identities = HashSet::new();
13754    for (txn_id, commit_epoch, added_runs) in spilled {
13755        for added in added_runs {
13756            if added.run_id >= u64::MAX as u128 {
13757                return Err(MongrelError::CorruptWal {
13758                    offset: *commit_epoch,
13759                    reason: format!(
13760                        "recovered run id {} exceeds the on-disk namespace",
13761                        added.run_id
13762                    ),
13763                });
13764            }
13765            let Some(handle) = tables.get(&added.table_id) else {
13766                continue;
13767            };
13768            if !identities.insert((added.table_id, added.run_id)) {
13769                return Err(MongrelError::CorruptWal {
13770                    offset: *commit_epoch,
13771                    reason: format!(
13772                        "duplicate recovered run {} for table {}",
13773                        added.run_id, added.table_id
13774                    ),
13775                });
13776            }
13777            let table = handle.lock();
13778            validate_planned_spilled_run(
13779                root,
13780                *txn_id,
13781                *commit_epoch,
13782                added,
13783                table.schema(),
13784                table.kek(),
13785            )?;
13786        }
13787    }
13788    Ok(())
13789}
13790
13791fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
13792    match condition {
13793        ProcedureCondition::Pk { .. } => {
13794            if schema.primary_key().is_none() {
13795                return Err(MongrelError::InvalidArgument(
13796                    "procedure condition Pk references a table without a primary key".into(),
13797                ));
13798            }
13799        }
13800        ProcedureCondition::BitmapEq { column_id, .. }
13801        | ProcedureCondition::BitmapIn { column_id, .. }
13802        | ProcedureCondition::Range { column_id, .. }
13803        | ProcedureCondition::RangeF64 { column_id, .. }
13804        | ProcedureCondition::IsNull { column_id }
13805        | ProcedureCondition::IsNotNull { column_id }
13806        | ProcedureCondition::FmContains { column_id, .. } => {
13807            validate_column_id(*column_id, schema)?;
13808        }
13809    }
13810    Ok(())
13811}
13812
13813fn bind_procedure_args(
13814    procedure: &StoredProcedure,
13815    mut args: HashMap<String, crate::Value>,
13816) -> Result<HashMap<String, crate::Value>> {
13817    let mut out = HashMap::new();
13818    for param in &procedure.params {
13819        let value = match args.remove(&param.name) {
13820            Some(value) => value,
13821            None => param.default.clone().ok_or_else(|| {
13822                MongrelError::InvalidArgument(format!(
13823                    "missing required procedure parameter {:?}",
13824                    param.name
13825                ))
13826            })?,
13827        };
13828        if !param.nullable && matches!(value, crate::Value::Null) {
13829            return Err(MongrelError::InvalidArgument(format!(
13830                "procedure parameter {:?} must not be NULL",
13831                param.name
13832            )));
13833        }
13834        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
13835            return Err(MongrelError::InvalidArgument(format!(
13836                "procedure parameter {:?} has wrong type",
13837                param.name
13838            )));
13839        }
13840        out.insert(param.name.clone(), value);
13841    }
13842    if let Some(extra) = args.keys().next() {
13843        return Err(MongrelError::InvalidArgument(format!(
13844            "unknown procedure parameter {extra:?}"
13845        )));
13846    }
13847    Ok(out)
13848}
13849
13850fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
13851    matches!(
13852        (value, ty),
13853        (crate::Value::Bool(_), crate::TypeId::Bool)
13854            | (crate::Value::Int64(_), crate::TypeId::Int8)
13855            | (crate::Value::Int64(_), crate::TypeId::Int16)
13856            | (crate::Value::Int64(_), crate::TypeId::Int32)
13857            | (crate::Value::Int64(_), crate::TypeId::Int64)
13858            | (crate::Value::Int64(_), crate::TypeId::UInt8)
13859            | (crate::Value::Int64(_), crate::TypeId::UInt16)
13860            | (crate::Value::Int64(_), crate::TypeId::UInt32)
13861            | (crate::Value::Int64(_), crate::TypeId::UInt64)
13862            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
13863            | (crate::Value::Int64(_), crate::TypeId::Date32)
13864            | (crate::Value::Float64(_), crate::TypeId::Float32)
13865            | (crate::Value::Float64(_), crate::TypeId::Float64)
13866            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
13867            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
13868    )
13869}
13870
13871fn eval_cells(
13872    cells: &[crate::procedure::ProcedureCell],
13873    args: &HashMap<String, crate::Value>,
13874    outputs: &HashMap<String, ProcedureCallOutput>,
13875) -> Result<Vec<(u16, crate::Value)>> {
13876    cells
13877        .iter()
13878        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
13879        .collect()
13880}
13881
13882fn eval_condition(
13883    condition: &ProcedureCondition,
13884    args: &HashMap<String, crate::Value>,
13885    outputs: &HashMap<String, ProcedureCallOutput>,
13886) -> Result<crate::Condition> {
13887    Ok(match condition {
13888        ProcedureCondition::Pk { value } => {
13889            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
13890        }
13891        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
13892            column_id: *column_id,
13893            value: eval_value(value, args, outputs)?.encode_key(),
13894        },
13895        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
13896            column_id: *column_id,
13897            values: values
13898                .iter()
13899                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
13900                .collect::<Result<Vec<_>>>()?,
13901        },
13902        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
13903            column_id: *column_id,
13904            lo: expect_i64(eval_value(lo, args, outputs)?)?,
13905            hi: expect_i64(eval_value(hi, args, outputs)?)?,
13906        },
13907        ProcedureCondition::RangeF64 {
13908            column_id,
13909            lo,
13910            lo_inclusive,
13911            hi,
13912            hi_inclusive,
13913        } => crate::Condition::RangeF64 {
13914            column_id: *column_id,
13915            lo: expect_f64(eval_value(lo, args, outputs)?)?,
13916            lo_inclusive: *lo_inclusive,
13917            hi: expect_f64(eval_value(hi, args, outputs)?)?,
13918            hi_inclusive: *hi_inclusive,
13919        },
13920        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
13921            column_id: *column_id,
13922        },
13923        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
13924            column_id: *column_id,
13925        },
13926        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
13927            column_id: *column_id,
13928            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
13929        },
13930    })
13931}
13932
13933fn eval_value(
13934    value: &ProcedureValue,
13935    args: &HashMap<String, crate::Value>,
13936    outputs: &HashMap<String, ProcedureCallOutput>,
13937) -> Result<crate::Value> {
13938    match value {
13939        ProcedureValue::Literal(value) => Ok(value.clone()),
13940        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
13941            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13942        }),
13943        ProcedureValue::StepScalar(id) => match outputs.get(id) {
13944            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
13945            _ => Err(MongrelError::InvalidArgument(format!(
13946                "procedure step {id:?} did not return a scalar"
13947            ))),
13948        },
13949        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
13950            Err(MongrelError::InvalidArgument(
13951                "row-valued procedure reference cannot be used as a scalar".into(),
13952            ))
13953        }
13954        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
13955            "structured procedure value cannot be used as a scalar cell".into(),
13956        )),
13957    }
13958}
13959
13960fn eval_return_output(
13961    value: &ProcedureValue,
13962    args: &HashMap<String, crate::Value>,
13963    outputs: &HashMap<String, ProcedureCallOutput>,
13964) -> Result<ProcedureCallOutput> {
13965    match value {
13966        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
13967        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
13968            args.get(name).cloned().ok_or_else(|| {
13969                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13970            })?,
13971        )),
13972        ProcedureValue::StepRows(id)
13973        | ProcedureValue::StepRow(id)
13974        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
13975            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
13976        }),
13977        ProcedureValue::Object(fields) => {
13978            let mut out = Vec::with_capacity(fields.len());
13979            for (name, value) in fields {
13980                out.push((name.clone(), eval_return_output(value, args, outputs)?));
13981            }
13982            Ok(ProcedureCallOutput::Object(out))
13983        }
13984        ProcedureValue::Array(values) => {
13985            let mut out = Vec::with_capacity(values.len());
13986            for value in values {
13987                out.push(eval_return_output(value, args, outputs)?);
13988            }
13989            Ok(ProcedureCallOutput::Array(out))
13990        }
13991    }
13992}
13993
13994fn expect_i64(value: crate::Value) -> Result<i64> {
13995    match value {
13996        crate::Value::Int64(value) => Ok(value),
13997        _ => Err(MongrelError::InvalidArgument(
13998            "procedure value must be Int64".into(),
13999        )),
14000    }
14001}
14002
14003fn expect_f64(value: crate::Value) -> Result<f64> {
14004    match value {
14005        crate::Value::Float64(value) => Ok(value),
14006        _ => Err(MongrelError::InvalidArgument(
14007            "procedure value must be Float64".into(),
14008        )),
14009    }
14010}
14011
14012fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
14013    match value {
14014        crate::Value::Bytes(value) => Ok(value),
14015        _ => Err(MongrelError::InvalidArgument(
14016            "procedure value must be Bytes".into(),
14017        )),
14018    }
14019}
14020
14021fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
14022    if schema.columns.iter().any(|c| c.id == column_id) {
14023        Ok(())
14024    } else {
14025        Err(MongrelError::InvalidArgument(format!(
14026            "unknown column id {column_id}"
14027        )))
14028    }
14029}
14030
14031fn trigger_matches_event(
14032    trigger: &StoredTrigger,
14033    event: &WriteEvent,
14034    cat: &Catalog,
14035) -> Result<bool> {
14036    if trigger.event != event.kind {
14037        return Ok(false);
14038    }
14039    let TriggerTarget::Table(target) = &trigger.target else {
14040        return Ok(false);
14041    };
14042    if target != &event.table {
14043        return Ok(false);
14044    }
14045    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
14046        let schema = &cat
14047            .live(target)
14048            .ok_or_else(|| {
14049                MongrelError::InvalidArgument(format!(
14050                    "trigger {:?} references unknown table {target:?}",
14051                    trigger.name
14052                ))
14053            })?
14054            .schema;
14055        let mut watched = Vec::with_capacity(trigger.update_of.len());
14056        for name in &trigger.update_of {
14057            let col = schema.column(name).ok_or_else(|| {
14058                MongrelError::InvalidArgument(format!(
14059                    "trigger {:?} references unknown UPDATE OF column {name:?}",
14060                    trigger.name
14061                ))
14062            })?;
14063            watched.push(col.id);
14064        }
14065        if !event
14066            .changed_columns
14067            .iter()
14068            .any(|column_id| watched.contains(column_id))
14069        {
14070            return Ok(false);
14071        }
14072    }
14073    Ok(true)
14074}
14075
14076fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
14077    let mut ids = std::collections::BTreeSet::new();
14078    if let Some(old) = old {
14079        ids.extend(old.columns.keys().copied());
14080    }
14081    if let Some(new) = new {
14082        ids.extend(new.columns.keys().copied());
14083    }
14084    ids.into_iter()
14085        .filter(|id| {
14086            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
14087        })
14088        .collect()
14089}
14090
14091fn eval_trigger_cells(
14092    cells: &[crate::trigger::TriggerCell],
14093    event: &WriteEvent,
14094    selected: Option<&TriggerRowImage>,
14095) -> Result<Vec<(u16, Value)>> {
14096    cells
14097        .iter()
14098        .map(|cell| {
14099            Ok((
14100                cell.column_id,
14101                eval_trigger_value(&cell.value, event, selected)?,
14102            ))
14103        })
14104        .collect()
14105}
14106
14107fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
14108    match expr {
14109        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
14110            Value::Bool(value) => Ok(value),
14111            Value::Null => Ok(false),
14112            other => Err(MongrelError::InvalidArgument(format!(
14113                "trigger WHEN value must be boolean, got {other:?}"
14114            ))),
14115        },
14116        TriggerExpr::Eq { left, right } => Ok(values_equal(
14117            &eval_trigger_value(left, event, None)?,
14118            &eval_trigger_value(right, event, None)?,
14119        )),
14120        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
14121            &eval_trigger_value(left, event, None)?,
14122            &eval_trigger_value(right, event, None)?,
14123        )),
14124        TriggerExpr::Lt { left, right } => match value_order(
14125            &eval_trigger_value(left, event, None)?,
14126            &eval_trigger_value(right, event, None)?,
14127        ) {
14128            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
14129            None => Ok(false),
14130        },
14131        TriggerExpr::Lte { left, right } => match value_order(
14132            &eval_trigger_value(left, event, None)?,
14133            &eval_trigger_value(right, event, None)?,
14134        ) {
14135            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
14136            None => Ok(false),
14137        },
14138        TriggerExpr::Gt { left, right } => match value_order(
14139            &eval_trigger_value(left, event, None)?,
14140            &eval_trigger_value(right, event, None)?,
14141        ) {
14142            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
14143            None => Ok(false),
14144        },
14145        TriggerExpr::Gte { left, right } => match value_order(
14146            &eval_trigger_value(left, event, None)?,
14147            &eval_trigger_value(right, event, None)?,
14148        ) {
14149            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
14150            None => Ok(false),
14151        },
14152        TriggerExpr::IsNull(value) => Ok(matches!(
14153            eval_trigger_value(value, event, None)?,
14154            Value::Null
14155        )),
14156        TriggerExpr::IsNotNull(value) => Ok(!matches!(
14157            eval_trigger_value(value, event, None)?,
14158            Value::Null
14159        )),
14160        TriggerExpr::And { left, right } => {
14161            if !eval_trigger_expr(left, event)? {
14162                Ok(false)
14163            } else {
14164                Ok(eval_trigger_expr(right, event)?)
14165            }
14166        }
14167        TriggerExpr::Or { left, right } => {
14168            if eval_trigger_expr(left, event)? {
14169                Ok(true)
14170            } else {
14171                Ok(eval_trigger_expr(right, event)?)
14172            }
14173        }
14174        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
14175    }
14176}
14177
14178fn eval_trigger_condition(
14179    condition: &TriggerCondition,
14180    event: &WriteEvent,
14181    selected: &TriggerRowImage,
14182    schema: &Schema,
14183) -> Result<bool> {
14184    match condition {
14185        TriggerCondition::Pk { value } => {
14186            let pk = schema.primary_key().ok_or_else(|| {
14187                MongrelError::InvalidArgument(
14188                    "trigger condition Pk references a table without a primary key".into(),
14189                )
14190            })?;
14191            let lhs = eval_trigger_value(value, event, Some(selected))?;
14192            Ok(values_equal(
14193                &lhs,
14194                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
14195            ))
14196        }
14197        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
14198            selected.columns.get(column_id).unwrap_or(&Value::Null),
14199            &eval_trigger_value(value, event, Some(selected))?,
14200        )),
14201        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
14202            selected.columns.get(column_id).unwrap_or(&Value::Null),
14203            &eval_trigger_value(value, event, Some(selected))?,
14204        )),
14205        TriggerCondition::Lt { column_id, value } => match value_order(
14206            selected.columns.get(column_id).unwrap_or(&Value::Null),
14207            &eval_trigger_value(value, event, Some(selected))?,
14208        ) {
14209            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
14210            None => Ok(false),
14211        },
14212        TriggerCondition::Lte { column_id, value } => match value_order(
14213            selected.columns.get(column_id).unwrap_or(&Value::Null),
14214            &eval_trigger_value(value, event, Some(selected))?,
14215        ) {
14216            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
14217            None => Ok(false),
14218        },
14219        TriggerCondition::Gt { column_id, value } => match value_order(
14220            selected.columns.get(column_id).unwrap_or(&Value::Null),
14221            &eval_trigger_value(value, event, Some(selected))?,
14222        ) {
14223            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
14224            None => Ok(false),
14225        },
14226        TriggerCondition::Gte { column_id, value } => match value_order(
14227            selected.columns.get(column_id).unwrap_or(&Value::Null),
14228            &eval_trigger_value(value, event, Some(selected))?,
14229        ) {
14230            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
14231            None => Ok(false),
14232        },
14233        TriggerCondition::IsNull { column_id } => Ok(matches!(
14234            selected.columns.get(column_id),
14235            None | Some(Value::Null)
14236        )),
14237        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
14238            selected.columns.get(column_id),
14239            None | Some(Value::Null)
14240        )),
14241        TriggerCondition::And { left, right } => {
14242            if !eval_trigger_condition(left, event, selected, schema)? {
14243                Ok(false)
14244            } else {
14245                Ok(eval_trigger_condition(right, event, selected, schema)?)
14246            }
14247        }
14248        TriggerCondition::Or { left, right } => {
14249            if eval_trigger_condition(left, event, selected, schema)? {
14250                Ok(true)
14251            } else {
14252                Ok(eval_trigger_condition(right, event, selected, schema)?)
14253            }
14254        }
14255        TriggerCondition::Not(condition) => {
14256            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
14257        }
14258    }
14259}
14260
14261fn eval_trigger_value(
14262    value: &TriggerValue,
14263    event: &WriteEvent,
14264    selected: Option<&TriggerRowImage>,
14265) -> Result<Value> {
14266    match value {
14267        TriggerValue::Literal(value) => Ok(value.clone()),
14268        TriggerValue::NewColumn(column_id) => event
14269            .new
14270            .as_ref()
14271            .and_then(|row| row.columns.get(column_id))
14272            .cloned()
14273            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
14274        TriggerValue::OldColumn(column_id) => event
14275            .old
14276            .as_ref()
14277            .and_then(|row| row.columns.get(column_id))
14278            .cloned()
14279            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
14280        TriggerValue::SelectedColumn(column_id) => selected
14281            .and_then(|row| row.columns.get(column_id))
14282            .cloned()
14283            .ok_or_else(|| {
14284                MongrelError::InvalidArgument("SELECTED column is not available".into())
14285            }),
14286    }
14287}
14288
14289fn values_equal(left: &Value, right: &Value) -> bool {
14290    match (left, right) {
14291        (Value::Null, Value::Null) => true,
14292        (Value::Bool(a), Value::Bool(b)) => a == b,
14293        (Value::Int64(a), Value::Int64(b)) => a == b,
14294        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
14295        (Value::Bytes(a), Value::Bytes(b)) => a == b,
14296        (Value::Embedding(a), Value::Embedding(b)) => {
14297            a.len() == b.len()
14298                && a.iter()
14299                    .zip(b.iter())
14300                    .all(|(a, b)| a.to_bits() == b.to_bits())
14301        }
14302        _ => false,
14303    }
14304}
14305
14306fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
14307    match (left, right) {
14308        (Value::Null, _) | (_, Value::Null) => None,
14309        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
14310        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
14311        // Cross-type Int64/Float64 comparison coerces the integer to f64.
14312        // This matches the spec but can lose precision for i64 values above 2^53.
14313        (Value::Int64(a), Value::Float64(b)) => {
14314            let af = *a as f64;
14315            Some(af.total_cmp(b))
14316        }
14317        // Cross-type Int64/Float64 comparison coerces the integer to f64.
14318        // This matches the spec but can lose precision for i64 values above 2^53.
14319        (Value::Float64(a), Value::Int64(b)) => {
14320            let bf = *b as f64;
14321            Some(a.total_cmp(&bf))
14322        }
14323        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
14324        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
14325        (Value::Embedding(_), Value::Embedding(_)) => None,
14326        _ => None,
14327    }
14328}
14329
14330fn trigger_message(value: Value) -> String {
14331    match value {
14332        Value::Null => "NULL".into(),
14333        Value::Bool(value) => value.to_string(),
14334        Value::Int64(value) => value.to_string(),
14335        Value::Float64(value) => value.to_string(),
14336        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
14337        Value::Embedding(value) => format!("{value:?}"),
14338        Value::Decimal(value) => value.to_string(),
14339        Value::Interval {
14340            months,
14341            days,
14342            nanos,
14343        } => format!("{months}m {days}d {nanos}ns"),
14344        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
14345        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
14346    }
14347}
14348
14349fn validate_trigger_step<'a>(
14350    step: &TriggerStep,
14351    cat: &'a Catalog,
14352    target_schema: &Schema,
14353    event: TriggerEvent,
14354    select_schemas: &mut HashMap<String, &'a Schema>,
14355) -> Result<()> {
14356    match step {
14357        TriggerStep::SetNew { cells } => {
14358            if event == TriggerEvent::Delete {
14359                return Err(MongrelError::InvalidArgument(
14360                    "SetNew trigger step is not valid for DELETE triggers".into(),
14361                ));
14362            }
14363            for cell in cells {
14364                validate_column_id(cell.column_id, target_schema)?;
14365                validate_trigger_value(&cell.value, target_schema, event)?;
14366            }
14367        }
14368        TriggerStep::Insert { table, cells } => {
14369            let schema = trigger_write_schema(cat, table, "insert")?;
14370            for cell in cells {
14371                validate_column_id(cell.column_id, schema)?;
14372                validate_trigger_value(&cell.value, target_schema, event)?;
14373            }
14374        }
14375        TriggerStep::UpdateByPk { table, pk, cells } => {
14376            let schema = trigger_write_schema(cat, table, "update")?;
14377            if schema.primary_key().is_none() {
14378                return Err(MongrelError::InvalidArgument(format!(
14379                    "trigger update_by_pk references table {table:?} without a primary key"
14380                )));
14381            }
14382            validate_trigger_value(pk, target_schema, event)?;
14383            for cell in cells {
14384                validate_column_id(cell.column_id, schema)?;
14385                validate_trigger_value(&cell.value, target_schema, event)?;
14386            }
14387        }
14388        TriggerStep::DeleteByPk { table, pk } => {
14389            let schema = trigger_write_schema(cat, table, "delete")?;
14390            if schema.primary_key().is_none() {
14391                return Err(MongrelError::InvalidArgument(format!(
14392                    "trigger delete_by_pk references table {table:?} without a primary key"
14393                )));
14394            }
14395            validate_trigger_value(pk, target_schema, event)?;
14396        }
14397        TriggerStep::Select {
14398            id,
14399            table,
14400            conditions,
14401        } => {
14402            let schema = trigger_read_schema(cat, table)?;
14403            for condition in conditions {
14404                validate_trigger_condition(condition, schema, target_schema, event)?;
14405            }
14406            if select_schemas.contains_key(id) {
14407                return Err(MongrelError::InvalidArgument(format!(
14408                    "duplicate select id {id:?} in trigger program"
14409                )));
14410            }
14411            select_schemas.insert(id.clone(), schema);
14412        }
14413        TriggerStep::Foreach { id, steps } => {
14414            if !select_schemas.contains_key(id) {
14415                return Err(MongrelError::InvalidArgument(format!(
14416                    "foreach references unknown select id {id:?}"
14417                )));
14418            }
14419            let mut inner_select_schemas = select_schemas.clone();
14420            for step in steps {
14421                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
14422            }
14423        }
14424        TriggerStep::DeleteWhere { table, conditions } => {
14425            let schema = trigger_write_schema(cat, table, "delete")?;
14426            for condition in conditions {
14427                validate_trigger_condition(condition, schema, target_schema, event)?;
14428            }
14429        }
14430        TriggerStep::UpdateWhere {
14431            table,
14432            conditions,
14433            cells,
14434        } => {
14435            let schema = trigger_write_schema(cat, table, "update")?;
14436            for condition in conditions {
14437                validate_trigger_condition(condition, schema, target_schema, event)?;
14438            }
14439            for cell in cells {
14440                validate_column_id(cell.column_id, schema)?;
14441                validate_trigger_value(&cell.value, target_schema, event)?;
14442            }
14443        }
14444        TriggerStep::Raise { message, .. } => {
14445            validate_trigger_value(message, target_schema, event)?
14446        }
14447    }
14448    Ok(())
14449}
14450
14451fn trigger_validation_error(error: MongrelError) -> MongrelError {
14452    match error {
14453        MongrelError::TriggerValidation(_) => error,
14454        MongrelError::InvalidArgument(message)
14455        | MongrelError::Conflict(message)
14456        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
14457        error => error,
14458    }
14459}
14460
14461fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
14462    if let Some(entry) = cat.live(table) {
14463        return Ok(&entry.schema);
14464    }
14465    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14466        let allowed = match op {
14467            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
14468            "update" | "delete" => entry.capabilities.writable,
14469            _ => false,
14470        };
14471        if !allowed {
14472            return Err(MongrelError::InvalidArgument(format!(
14473                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
14474                entry.module
14475            )));
14476        }
14477        if !entry.capabilities.transaction_safe {
14478            return Err(MongrelError::InvalidArgument(format!(
14479                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
14480                entry.module
14481            )));
14482        }
14483        return Ok(&entry.declared_schema);
14484    }
14485    Err(MongrelError::InvalidArgument(format!(
14486        "trigger references unknown table {table:?}"
14487    )))
14488}
14489
14490fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
14491    if let Some(entry) = cat.live(table) {
14492        return Ok(&entry.schema);
14493    }
14494    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14495        if entry.capabilities.trigger_safe {
14496            return Ok(&entry.declared_schema);
14497        }
14498        return Err(MongrelError::InvalidArgument(format!(
14499            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
14500            entry.module
14501        )));
14502    }
14503    Err(MongrelError::InvalidArgument(format!(
14504        "trigger references unknown table {table:?}"
14505    )))
14506}
14507
14508fn validate_trigger_condition(
14509    condition: &TriggerCondition,
14510    schema: &Schema,
14511    target_schema: &Schema,
14512    event: TriggerEvent,
14513) -> Result<()> {
14514    match condition {
14515        TriggerCondition::Pk { value } => {
14516            if schema.primary_key().is_none() {
14517                return Err(MongrelError::InvalidArgument(
14518                    "trigger condition Pk references a table without a primary key".into(),
14519                ));
14520            }
14521            validate_trigger_value(value, target_schema, event)
14522        }
14523        TriggerCondition::Eq { column_id, value }
14524        | TriggerCondition::NotEq { column_id, value }
14525        | TriggerCondition::Lt { column_id, value }
14526        | TriggerCondition::Lte { column_id, value }
14527        | TriggerCondition::Gt { column_id, value }
14528        | TriggerCondition::Gte { column_id, value } => {
14529            validate_column_id(*column_id, schema)?;
14530            validate_trigger_value(value, target_schema, event)
14531        }
14532        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
14533            validate_column_id(*column_id, schema)
14534        }
14535        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
14536            validate_trigger_condition(left, schema, target_schema, event)?;
14537            validate_trigger_condition(right, schema, target_schema, event)
14538        }
14539        TriggerCondition::Not(condition) => {
14540            validate_trigger_condition(condition, schema, target_schema, event)
14541        }
14542    }
14543}
14544
14545fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
14546    match expr {
14547        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
14548            validate_trigger_value(value, schema, event)
14549        }
14550        TriggerExpr::Eq { left, right }
14551        | TriggerExpr::NotEq { left, right }
14552        | TriggerExpr::Lt { left, right }
14553        | TriggerExpr::Lte { left, right }
14554        | TriggerExpr::Gt { left, right }
14555        | TriggerExpr::Gte { left, right } => {
14556            validate_trigger_value(left, schema, event)?;
14557            validate_trigger_value(right, schema, event)
14558        }
14559        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
14560            validate_trigger_expr(left, schema, event)?;
14561            validate_trigger_expr(right, schema, event)
14562        }
14563        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
14564    }
14565}
14566
14567fn validate_trigger_value(
14568    value: &TriggerValue,
14569    schema: &Schema,
14570    event: TriggerEvent,
14571) -> Result<()> {
14572    match value {
14573        TriggerValue::Literal(_) => Ok(()),
14574        TriggerValue::NewColumn(id) => {
14575            if event == TriggerEvent::Delete {
14576                return Err(MongrelError::InvalidArgument(
14577                    "DELETE triggers cannot reference NEW".into(),
14578                ));
14579            }
14580            validate_column_id(*id, schema)
14581        }
14582        TriggerValue::OldColumn(id) => {
14583            if event == TriggerEvent::Insert {
14584                return Err(MongrelError::InvalidArgument(
14585                    "INSERT triggers cannot reference OLD".into(),
14586                ));
14587            }
14588            validate_column_id(*id, schema)
14589        }
14590        // SELECTED column references are only meaningful inside a foreach loop.
14591        // Strict loop-scope validation is deferred to runtime; the executor raises
14592        // an error if a selected row is not available.
14593        TriggerValue::SelectedColumn(_) => Ok(()),
14594    }
14595}
14596
14597/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
14598/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
14599/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
14600/// catalog. This pass closes that window by reconstructing missing entries
14601/// (and marking committed drops) before tables are mounted.
14602fn recover_ddl_from_wal(
14603    root: &Path,
14604    durable_root: Option<&crate::durable_file::DurableRoot>,
14605    target_catalog: &mut Catalog,
14606    meta_dek: Option<&[u8; META_DEK_LEN]>,
14607    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
14608    apply: bool,
14609    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14610) -> Result<()> {
14611    use crate::wal::SharedWal;
14612    let records = match durable_root {
14613        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
14614        None => SharedWal::replay_with_dek(root, wal_dek)?,
14615    };
14616    recover_ddl_from_records(
14617        root,
14618        durable_root,
14619        target_catalog,
14620        meta_dek,
14621        apply,
14622        table_roots,
14623        &records,
14624    )
14625}
14626
14627fn recover_ddl_from_records(
14628    root: &Path,
14629    durable_root: Option<&crate::durable_file::DurableRoot>,
14630    target_catalog: &mut Catalog,
14631    meta_dek: Option<&[u8; META_DEK_LEN]>,
14632    apply: bool,
14633    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14634    records: &[crate::wal::Record],
14635) -> Result<()> {
14636    use crate::wal::{DdlOp, Op};
14637
14638    let original_catalog = target_catalog.clone();
14639    let mut recovered_catalog = original_catalog.clone();
14640    let cat = &mut recovered_catalog;
14641    let mut created_table_ids = HashSet::<u64>::new();
14642    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
14643
14644    let mut committed: HashMap<u64, u64> = HashMap::new();
14645    for r in records {
14646        if let Op::TxnCommit { epoch: ce, .. } = r.op {
14647            committed.insert(r.txn_id, ce);
14648        }
14649    }
14650    let catalog_snapshot_txns = records
14651        .iter()
14652        .filter_map(|record| {
14653            (committed.contains_key(&record.txn_id)
14654                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
14655            .then_some(record.txn_id)
14656        })
14657        .collect::<HashSet<_>>();
14658
14659    let mut changed = false;
14660    let mut applied_catalog_epoch = cat.db_epoch;
14661    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
14662    for r in records.iter().cloned() {
14663        let Some(&ce) = committed.get(&r.txn_id) else {
14664            continue;
14665        };
14666        let txn_id = r.txn_id;
14667        match r.op {
14668            Op::Ddl(DdlOp::CreateTable {
14669                table_id,
14670                ref name,
14671                ref schema_json,
14672            }) => {
14673                if cat.tables.iter().any(|t| t.table_id == table_id) {
14674                    continue;
14675                }
14676                let schema = DdlOp::decode_schema(schema_json)?;
14677                validate_recovered_schema(&schema)?;
14678                created_table_ids.insert(table_id);
14679                cat.tables.push(CatalogEntry {
14680                    table_id,
14681                    name: name.clone(),
14682                    schema,
14683                    state: TableState::Live,
14684                    created_epoch: ce,
14685                });
14686                cat.next_table_id =
14687                    cat.next_table_id
14688                        .max(table_id.checked_add(1).ok_or_else(|| {
14689                            MongrelError::Full("table id namespace exhausted".into())
14690                        })?);
14691                changed = true;
14692            }
14693            Op::Ddl(DdlOp::CreateBuildingTable {
14694                table_id,
14695                ref build_name,
14696                ref intended_name,
14697                ref query_id,
14698                created_at_unix_nanos,
14699                ref schema_json,
14700            }) => {
14701                if cat.tables.iter().any(|table| table.table_id == table_id) {
14702                    continue;
14703                }
14704                let schema = DdlOp::decode_schema(schema_json)?;
14705                validate_recovered_schema(&schema)?;
14706                created_table_ids.insert(table_id);
14707                cat.tables.push(CatalogEntry {
14708                    table_id,
14709                    name: build_name.clone(),
14710                    schema,
14711                    state: TableState::Building {
14712                        intended_name: intended_name.clone(),
14713                        query_id: query_id.clone(),
14714                        created_at_unix_nanos,
14715                        replaces_table_id: None,
14716                    },
14717                    created_epoch: ce,
14718                });
14719                cat.next_table_id =
14720                    cat.next_table_id
14721                        .max(table_id.checked_add(1).ok_or_else(|| {
14722                            MongrelError::Full("table id namespace exhausted".into())
14723                        })?);
14724                changed = true;
14725            }
14726            Op::Ddl(DdlOp::CreateRebuildingTable {
14727                table_id,
14728                ref build_name,
14729                ref intended_name,
14730                ref query_id,
14731                created_at_unix_nanos,
14732                replaces_table_id,
14733                ref schema_json,
14734            }) => {
14735                if cat.tables.iter().any(|table| table.table_id == table_id) {
14736                    continue;
14737                }
14738                let schema = DdlOp::decode_schema(schema_json)?;
14739                validate_recovered_schema(&schema)?;
14740                created_table_ids.insert(table_id);
14741                cat.tables.push(CatalogEntry {
14742                    table_id,
14743                    name: build_name.clone(),
14744                    schema,
14745                    state: TableState::Building {
14746                        intended_name: intended_name.clone(),
14747                        query_id: query_id.clone(),
14748                        created_at_unix_nanos,
14749                        replaces_table_id: Some(replaces_table_id),
14750                    },
14751                    created_epoch: ce,
14752                });
14753                cat.next_table_id =
14754                    cat.next_table_id
14755                        .max(table_id.checked_add(1).ok_or_else(|| {
14756                            MongrelError::Full("table id namespace exhausted".into())
14757                        })?);
14758                changed = true;
14759            }
14760            Op::Ddl(DdlOp::DropTable { table_id }) => {
14761                let mut dropped_name = None;
14762                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14763                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
14764                        dropped_name = Some(entry.name.clone());
14765                        entry.state = TableState::Dropped { at_epoch: ce };
14766                        changed = true;
14767                    }
14768                }
14769                if let Some(name) = dropped_name {
14770                    let before = cat.materialized_views.len();
14771                    cat.materialized_views
14772                        .retain(|definition| definition.name != name);
14773                    changed |= before != cat.materialized_views.len();
14774                    cat.security.rls_tables.retain(|table| table != &name);
14775                    cat.security.policies.retain(|policy| policy.table != name);
14776                    cat.security.masks.retain(|mask| mask.table != name);
14777                    for role in &mut cat.roles {
14778                        role.permissions
14779                            .retain(|permission| permission_table(permission) != Some(&name));
14780                    }
14781                    if !catalog_snapshot_txns.contains(&txn_id) {
14782                        advance_security_version(cat)?;
14783                    }
14784                }
14785            }
14786            Op::Ddl(DdlOp::PublishBuildingTable {
14787                table_id,
14788                ref new_name,
14789            }) => {
14790                if let Some(entry) = cat
14791                    .tables
14792                    .iter_mut()
14793                    .find(|table| table.table_id == table_id)
14794                {
14795                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
14796                        entry.name = new_name.clone();
14797                        entry.state = TableState::Live;
14798                        changed = true;
14799                    }
14800                }
14801            }
14802            Op::Ddl(DdlOp::ReplaceBuildingTable {
14803                table_id,
14804                replaced_table_id,
14805                ref new_name,
14806            }) => {
14807                changed |=
14808                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
14809            }
14810            Op::Ddl(DdlOp::RenameTable {
14811                table_id,
14812                ref new_name,
14813            }) => {
14814                let mut old_name = None;
14815                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14816                    if entry.name != *new_name {
14817                        old_name = Some(entry.name.clone());
14818                        entry.name = new_name.clone();
14819                        changed = true;
14820                    }
14821                }
14822                if let Some(old_name) = old_name {
14823                    if let Some(definition) = cat
14824                        .materialized_views
14825                        .iter_mut()
14826                        .find(|definition| definition.name == old_name)
14827                    {
14828                        definition.name = new_name.clone();
14829                    }
14830                    for table in &mut cat.security.rls_tables {
14831                        if *table == old_name {
14832                            *table = new_name.clone();
14833                        }
14834                    }
14835                    for policy in &mut cat.security.policies {
14836                        if policy.table == old_name {
14837                            policy.table = new_name.clone();
14838                        }
14839                    }
14840                    for mask in &mut cat.security.masks {
14841                        if mask.table == old_name {
14842                            mask.table = new_name.clone();
14843                        }
14844                    }
14845                    for role in &mut cat.roles {
14846                        for permission in &mut role.permissions {
14847                            rename_permission_table(permission, &old_name, new_name);
14848                        }
14849                    }
14850                    if !catalog_snapshot_txns.contains(&txn_id) {
14851                        advance_security_version(cat)?;
14852                    }
14853                }
14854                // If the entry is absent, its CreateTable was already
14855                // checkpointed carrying the post-rename name, so there is
14856                // nothing to apply — a no-op, not an error.
14857            }
14858            Op::Ddl(DdlOp::AlterTable {
14859                table_id,
14860                ref column_json,
14861            }) => {
14862                let column = DdlOp::decode_column(column_json)?;
14863                let mut renamed = None;
14864                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14865                    renamed = entry
14866                        .schema
14867                        .columns
14868                        .iter()
14869                        .find(|existing| existing.id == column.id && existing.name != column.name)
14870                        .map(|existing| {
14871                            (
14872                                entry.name.clone(),
14873                                existing.name.clone(),
14874                                column.name.clone(),
14875                            )
14876                        });
14877                    if apply_recovered_column_def(&mut entry.schema, column)? {
14878                        validate_recovered_schema(&entry.schema)?;
14879                        changed = true;
14880                    }
14881                }
14882                if let Some((table, old_name, new_name)) = renamed {
14883                    for role in &mut cat.roles {
14884                        for permission in &mut role.permissions {
14885                            rename_permission_column(permission, &table, &old_name, &new_name);
14886                        }
14887                    }
14888                    if !catalog_snapshot_txns.contains(&txn_id) {
14889                        advance_security_version(cat)?;
14890                    }
14891                }
14892            }
14893            Op::Ddl(DdlOp::SetTtl {
14894                table_id,
14895                ref policy_json,
14896            }) => {
14897                let policy = DdlOp::decode_ttl(policy_json)?;
14898                let entry = cat
14899                    .tables
14900                    .iter()
14901                    .find(|entry| entry.table_id == table_id)
14902                    .ok_or_else(|| {
14903                        MongrelError::Schema(format!(
14904                            "recovered TTL references unknown table id {table_id}"
14905                        ))
14906                    })?;
14907                if let Some(policy) = policy {
14908                    let valid = entry
14909                        .schema
14910                        .columns
14911                        .iter()
14912                        .find(|column| column.id == policy.column_id)
14913                        .is_some_and(|column| {
14914                            column.ty == TypeId::TimestampNanos
14915                                && policy.duration_nanos > 0
14916                                && policy.duration_nanos <= i64::MAX as u64
14917                        });
14918                    if !valid {
14919                        return Err(MongrelError::Schema(format!(
14920                            "invalid recovered TTL policy for table id {table_id}"
14921                        )));
14922                    }
14923                }
14924                ttl_updates.insert(table_id, (policy, ce));
14925            }
14926            Op::Ddl(DdlOp::SetMaterializedView {
14927                ref name,
14928                ref definition_json,
14929            }) => {
14930                let definition = DdlOp::decode_materialized_view(definition_json)?;
14931                if definition.name != *name {
14932                    return Err(MongrelError::Schema(format!(
14933                        "materialized view WAL name mismatch: {name:?}"
14934                    )));
14935                }
14936                if cat.live(name).is_some() {
14937                    if let Some(existing) = cat
14938                        .materialized_views
14939                        .iter_mut()
14940                        .find(|existing| existing.name == *name)
14941                    {
14942                        if *existing != definition {
14943                            *existing = definition;
14944                            changed = true;
14945                        }
14946                    } else {
14947                        cat.materialized_views.push(definition);
14948                        changed = true;
14949                    }
14950                }
14951            }
14952            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
14953                let security = DdlOp::decode_security(security_json)?;
14954                validate_security_catalog(cat, &security)?;
14955                if cat.security != security {
14956                    cat.security = security;
14957                    if !catalog_snapshot_txns.contains(&txn_id) {
14958                        advance_security_version(cat)?;
14959                    }
14960                    changed = true;
14961                }
14962            }
14963            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
14964                let target = match key.as_str() {
14965                    "user_version" => &mut cat.user_version,
14966                    "application_id" => &mut cat.application_id,
14967                    _ => {
14968                        return Err(MongrelError::InvalidArgument(format!(
14969                            "unsupported recovered SQL pragma {key:?}"
14970                        )))
14971                    }
14972                };
14973                if *target != Some(value) {
14974                    *target = Some(value);
14975                    cat.db_epoch = cat.db_epoch.max(ce);
14976                    changed = true;
14977                }
14978            }
14979            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
14980                if ce <= applied_catalog_epoch {
14981                    continue;
14982                }
14983                let snapshot = DdlOp::decode_catalog(catalog_json)?;
14984                if snapshot.db_epoch != ce {
14985                    return Err(MongrelError::Schema(format!(
14986                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
14987                        snapshot.db_epoch
14988                    )));
14989                }
14990                validate_recovered_catalog(&snapshot)?;
14991                validate_catalog_transition(cat, &snapshot)?;
14992                *cat = snapshot;
14993                applied_catalog_epoch = ce;
14994                changed = true;
14995            }
14996            _ => {}
14997        }
14998    }
14999
15000    if cat.db_epoch < max_committed_epoch {
15001        cat.db_epoch = max_committed_epoch;
15002        changed = true;
15003    }
15004    changed |= repair_catalog_allocator_counters(cat)?;
15005
15006    validate_recovered_catalog(cat)?;
15007    let storage_reconciliation = validate_recovered_storage_plan(
15008        root,
15009        durable_root,
15010        cat,
15011        &created_table_ids,
15012        &ttl_updates,
15013        meta_dek,
15014    )?;
15015
15016    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
15017    if apply && (changed || needs_storage_apply) {
15018        for table_id in storage_reconciliation {
15019            let entry = cat
15020                .tables
15021                .iter()
15022                .find(|entry| entry.table_id == table_id)
15023                .ok_or_else(|| MongrelError::CorruptWal {
15024                    offset: table_id,
15025                    reason: "recovery storage plan lost its catalog table".into(),
15026                })?;
15027            ensure_recovered_table_storage(
15028                table_roots
15029                    .and_then(|roots| roots.get(&table_id))
15030                    .map(Arc::as_ref),
15031                durable_root,
15032                &root.join(TABLES_DIR).join(table_id.to_string()),
15033                table_id,
15034                &entry.schema,
15035                meta_dek,
15036            )?;
15037        }
15038        for (table_id, (policy, ttl_epoch)) in ttl_updates {
15039            let Some(entry) = cat.tables.iter().find(|entry| {
15040                entry.table_id == table_id
15041                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
15042            }) else {
15043                continue;
15044            };
15045            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
15046            {
15047                root.try_clone()?
15048            } else if let Some(root) = durable_root {
15049                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
15050            } else {
15051                crate::durable_file::DurableRoot::open(
15052                    root.join(TABLES_DIR).join(table_id.to_string()),
15053                )?
15054            };
15055            let table_dir = table_root.io_path()?;
15056            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
15057            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
15058                manifest.ttl = policy;
15059                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
15060                manifest.schema_id = entry.schema.schema_id;
15061                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
15062            }
15063        }
15064        if changed {
15065            match durable_root {
15066                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
15067                None => catalog::write_atomic(root, cat, meta_dek)?,
15068            }
15069        }
15070    }
15071    *target_catalog = recovered_catalog;
15072    Ok(())
15073}
15074
15075fn ensure_recovered_table_storage(
15076    pinned_table: Option<&crate::durable_file::DurableRoot>,
15077    durable_root: Option<&crate::durable_file::DurableRoot>,
15078    fallback_table_dir: &Path,
15079    table_id: u64,
15080    schema: &Schema,
15081    meta_dek: Option<&[u8; META_DEK_LEN]>,
15082) -> Result<()> {
15083    let table_root = if let Some(root) = pinned_table {
15084        root.try_clone()?
15085    } else if let Some(root) = durable_root {
15086        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
15087        match root.open_directory(&relative) {
15088            Ok(table) => table,
15089            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
15090                root.create_directory_all_pinned(relative)?
15091            }
15092            Err(error) => return Err(error.into()),
15093        }
15094    } else {
15095        crate::durable_file::create_directory_all(fallback_table_dir)?;
15096        crate::durable_file::DurableRoot::open(fallback_table_dir)?
15097    };
15098    let table_dir = table_root.io_path()?;
15099    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
15100        Ok(manifest) => {
15101            if manifest.table_id != table_id {
15102                return Err(MongrelError::Conflict(format!(
15103                    "recovered table directory id mismatch: expected {table_id}, found {}",
15104                    manifest.table_id
15105                )));
15106            }
15107            Some(manifest)
15108        }
15109        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
15110        Err(error) => return Err(error),
15111    };
15112
15113    table_root.create_directory_all(crate::engine::WAL_DIR)?;
15114    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
15115    crate::engine::write_schema(&table_dir, schema)?;
15116
15117    if let Some(mut manifest) = existing_manifest.take() {
15118        if manifest.schema_id != schema.schema_id {
15119            manifest.schema_id = schema.schema_id;
15120            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
15121        }
15122    } else {
15123        // The DB-wide meta DEK is also the per-table manifest meta DEK.
15124        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
15125        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
15126    }
15127    Ok(())
15128}
15129
15130fn validate_recovered_schema(schema: &Schema) -> Result<()> {
15131    schema.validate_auto_increment()?;
15132    schema.validate_defaults()?;
15133    schema.validate_ai()?;
15134    let mut column_ids = HashSet::new();
15135    let mut column_names = HashSet::new();
15136    for column in &schema.columns {
15137        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
15138            return Err(MongrelError::Schema(
15139                "recovered schema contains duplicate columns".into(),
15140            ));
15141        }
15142        match &column.ty {
15143            TypeId::Decimal128 { precision, scale }
15144                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
15145            {
15146                return Err(MongrelError::Schema(format!(
15147                    "column {:?} has invalid decimal precision or scale",
15148                    column.name
15149                )));
15150            }
15151            TypeId::Enum { variants }
15152                if variants.is_empty()
15153                    || variants.iter().any(String::is_empty)
15154                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
15155            {
15156                return Err(MongrelError::Schema(format!(
15157                    "column {:?} has invalid enum variants",
15158                    column.name
15159                )));
15160            }
15161            _ => {}
15162        }
15163    }
15164    let mut index_names = HashSet::new();
15165    for index in &schema.indexes {
15166        index.validate_options()?;
15167        if index.name.is_empty()
15168            || !index_names.insert(index.name.as_str())
15169            || schema
15170                .columns
15171                .iter()
15172                .all(|column| column.id != index.column_id)
15173        {
15174            return Err(MongrelError::Schema(format!(
15175                "recovered index {:?} references missing column {}",
15176                index.name, index.column_id
15177            )));
15178        }
15179    }
15180    let mut colocated = HashSet::new();
15181    for group in &schema.colocation {
15182        if group.is_empty()
15183            || group.iter().any(|id| !column_ids.contains(id))
15184            || group.iter().any(|id| !colocated.insert(*id))
15185        {
15186            return Err(MongrelError::Schema(
15187                "recovered schema contains invalid column co-location groups".into(),
15188            ));
15189        }
15190    }
15191
15192    let mut constraint_ids = HashSet::new();
15193    let mut constraint_names = HashSet::<String>::new();
15194    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
15195        if name.is_empty()
15196            || !constraint_ids.insert(id)
15197            || !constraint_names.insert(name.to_owned())
15198        {
15199            return Err(MongrelError::Schema(
15200                "recovered schema contains duplicate or empty constraint identities".into(),
15201            ));
15202        }
15203        Ok(())
15204    };
15205    for unique in &schema.constraints.uniques {
15206        validate_constraint_identity(unique.id, &unique.name)?;
15207        if unique.columns.is_empty()
15208            || unique.columns.iter().any(|id| !column_ids.contains(id))
15209            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
15210        {
15211            return Err(MongrelError::Schema(format!(
15212                "unique constraint {:?} has invalid columns",
15213                unique.name
15214            )));
15215        }
15216    }
15217    for foreign_key in &schema.constraints.foreign_keys {
15218        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
15219        if foreign_key.ref_table.is_empty()
15220            || foreign_key.columns.is_empty()
15221            || foreign_key.columns.len() != foreign_key.ref_columns.len()
15222            || foreign_key
15223                .columns
15224                .iter()
15225                .any(|id| !column_ids.contains(id))
15226            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
15227            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
15228                != foreign_key.ref_columns.len()
15229        {
15230            return Err(MongrelError::Schema(format!(
15231                "foreign key {:?} has invalid columns",
15232                foreign_key.name
15233            )));
15234        }
15235        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
15236            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
15237            && foreign_key.columns.iter().any(|id| {
15238                schema
15239                    .columns
15240                    .iter()
15241                    .find(|column| column.id == *id)
15242                    .is_none_or(|column| {
15243                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
15244                    })
15245            })
15246        {
15247            return Err(MongrelError::Schema(format!(
15248                "foreign key {:?} uses SET NULL on a non-nullable column",
15249                foreign_key.name
15250            )));
15251        }
15252    }
15253    for check in &schema.constraints.checks {
15254        validate_constraint_identity(check.id, &check.name)?;
15255        check.expr.validate()?;
15256        validate_check_columns(&check.expr, &column_ids)?;
15257    }
15258    Ok(())
15259}
15260
15261fn validate_check_columns(
15262    expression: &crate::constraint::CheckExpr,
15263    column_ids: &HashSet<u16>,
15264) -> Result<()> {
15265    use crate::constraint::CheckExpr;
15266    match expression {
15267        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
15268            if column_ids.contains(id) {
15269                Ok(())
15270            } else {
15271                Err(MongrelError::Schema(format!(
15272                    "check constraint references unknown column {id}"
15273                )))
15274            }
15275        }
15276        CheckExpr::Regex { col, .. } => {
15277            if column_ids.contains(col) {
15278                Ok(())
15279            } else {
15280                Err(MongrelError::Schema(format!(
15281                    "check constraint references unknown column {col}"
15282                )))
15283            }
15284        }
15285        CheckExpr::Add(left, right)
15286        | CheckExpr::Sub(left, right)
15287        | CheckExpr::Mul(left, right)
15288        | CheckExpr::Div(left, right)
15289        | CheckExpr::Mod(left, right)
15290        | CheckExpr::Eq(left, right)
15291        | CheckExpr::Ne(left, right)
15292        | CheckExpr::Lt(left, right)
15293        | CheckExpr::Le(left, right)
15294        | CheckExpr::Gt(left, right)
15295        | CheckExpr::Ge(left, right)
15296        | CheckExpr::And(left, right)
15297        | CheckExpr::Or(left, right) => {
15298            validate_check_columns(left, column_ids)?;
15299            validate_check_columns(right, column_ids)
15300        }
15301        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
15302        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
15303    }
15304}
15305
15306fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
15307    for (name, prior, candidate) in [
15308        ("db_epoch", current.db_epoch, next.db_epoch),
15309        ("next_table_id", current.next_table_id, next.next_table_id),
15310        (
15311            "next_segment_no",
15312            current.next_segment_no,
15313            next.next_segment_no,
15314        ),
15315        ("next_user_id", current.next_user_id, next.next_user_id),
15316        (
15317            "security_version",
15318            current.security_version,
15319            next.security_version,
15320        ),
15321    ] {
15322        if candidate < prior {
15323            return Err(MongrelError::Schema(format!(
15324                "catalog snapshot rolls back {name} from {prior} to {candidate}"
15325            )));
15326        }
15327    }
15328    for prior in &current.tables {
15329        let Some(candidate) = next
15330            .tables
15331            .iter()
15332            .find(|entry| entry.table_id == prior.table_id)
15333        else {
15334            return Err(MongrelError::Schema(format!(
15335                "catalog snapshot removes table identity {}",
15336                prior.table_id
15337            )));
15338        };
15339        if candidate.created_epoch != prior.created_epoch
15340            || candidate.schema.schema_id < prior.schema.schema_id
15341            || matches!(prior.state, TableState::Dropped { .. })
15342                && !matches!(candidate.state, TableState::Dropped { .. })
15343        {
15344            return Err(MongrelError::Schema(format!(
15345                "catalog snapshot rolls back table identity {}",
15346                prior.table_id
15347            )));
15348        }
15349    }
15350    for prior in &current.users {
15351        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
15352            if candidate.username != prior.username
15353                || candidate.created_epoch != prior.created_epoch
15354            {
15355                return Err(MongrelError::Schema(format!(
15356                    "catalog snapshot reuses user identity {}",
15357                    prior.id
15358                )));
15359            }
15360        }
15361    }
15362    Ok(())
15363}
15364
15365fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
15366    let mut table_ids = HashSet::new();
15367    let mut active_names = HashSet::new();
15368    let mut max_table_id = None::<u64>;
15369    for entry in &catalog.tables {
15370        if !table_ids.insert(entry.table_id) {
15371            return Err(MongrelError::Schema(format!(
15372                "catalog contains duplicate table id {}",
15373                entry.table_id
15374            )));
15375        }
15376        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
15377        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
15378            return Err(MongrelError::Schema(format!(
15379                "catalog table {} has invalid name or creation epoch",
15380                entry.table_id
15381            )));
15382        }
15383        validate_recovered_schema(&entry.schema)?;
15384        match &entry.state {
15385            TableState::Live => {
15386                if !active_names.insert(entry.name.as_str()) {
15387                    return Err(MongrelError::Schema(format!(
15388                        "catalog contains duplicate active table name {:?}",
15389                        entry.name
15390                    )));
15391                }
15392            }
15393            TableState::Dropped { at_epoch } => {
15394                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
15395                    return Err(MongrelError::Schema(format!(
15396                        "catalog table {} has invalid drop epoch {at_epoch}",
15397                        entry.table_id
15398                    )));
15399                }
15400            }
15401            TableState::Building {
15402                intended_name,
15403                query_id,
15404                replaces_table_id,
15405                ..
15406            } => {
15407                if intended_name.is_empty() || query_id.is_empty() {
15408                    return Err(MongrelError::Schema(format!(
15409                        "building table {} has empty identity fields",
15410                        entry.table_id
15411                    )));
15412                }
15413                if !active_names.insert(entry.name.as_str()) {
15414                    return Err(MongrelError::Schema(format!(
15415                        "catalog contains duplicate active/building table name {:?}",
15416                        entry.name
15417                    )));
15418                }
15419                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
15420                    return Err(MongrelError::Schema(
15421                        "building table cannot replace itself".into(),
15422                    ));
15423                }
15424            }
15425        }
15426    }
15427    if let Some(maximum) = max_table_id {
15428        let required = maximum
15429            .checked_add(1)
15430            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15431        if catalog.next_table_id < required {
15432            return Err(MongrelError::Schema(format!(
15433                "catalog next_table_id {} precedes required {required}",
15434                catalog.next_table_id
15435            )));
15436        }
15437    }
15438    for entry in &catalog.tables {
15439        if let TableState::Building {
15440            replaces_table_id: Some(replaced),
15441            ..
15442        } = entry.state
15443        {
15444            if !table_ids.contains(&replaced) {
15445                return Err(MongrelError::Schema(format!(
15446                    "building table {} replaces unknown table {replaced}",
15447                    entry.table_id
15448                )));
15449            }
15450        }
15451    }
15452    for entry in &catalog.tables {
15453        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15454            validate_foreign_key_targets(catalog, &entry.schema)?;
15455        }
15456    }
15457
15458    let mut external_names = HashSet::new();
15459    for entry in &catalog.external_tables {
15460        entry.validate()?;
15461        validate_recovered_schema(&entry.declared_schema)?;
15462        if !entry.declared_schema.constraints.is_empty() {
15463            return Err(MongrelError::Schema(format!(
15464                "external table {:?} cannot carry engine-enforced constraints",
15465                entry.name
15466            )));
15467        }
15468        if entry.created_epoch > catalog.db_epoch
15469            || !external_names.insert(entry.name.as_str())
15470            || active_names.contains(entry.name.as_str())
15471        {
15472            return Err(MongrelError::Schema(format!(
15473                "invalid or duplicate external table {:?}",
15474                entry.name
15475            )));
15476        }
15477    }
15478
15479    let mut procedure_names = HashSet::new();
15480    for entry in &catalog.procedures {
15481        entry.procedure.validate()?;
15482        if entry.procedure.created_epoch > entry.procedure.updated_epoch
15483            || entry.procedure.updated_epoch > catalog.db_epoch
15484            || !procedure_names.insert(entry.procedure.name.as_str())
15485        {
15486            return Err(MongrelError::Schema(format!(
15487                "invalid or duplicate procedure {:?}",
15488                entry.procedure.name
15489            )));
15490        }
15491        validate_recovered_procedure_references(catalog, &entry.procedure)?;
15492    }
15493
15494    let mut trigger_names = HashSet::new();
15495    for entry in &catalog.triggers {
15496        entry.trigger.validate()?;
15497        if entry.trigger.created_epoch > entry.trigger.updated_epoch
15498            || entry.trigger.updated_epoch > catalog.db_epoch
15499            || !trigger_names.insert(entry.trigger.name.as_str())
15500        {
15501            return Err(MongrelError::Schema(format!(
15502                "invalid or duplicate trigger {:?}",
15503                entry.trigger.name
15504            )));
15505        }
15506        validate_recovered_trigger_references(catalog, &entry.trigger)?;
15507    }
15508
15509    let mut views = HashSet::new();
15510    for view in &catalog.materialized_views {
15511        let target = catalog.live(&view.name).ok_or_else(|| {
15512            MongrelError::Schema(format!(
15513                "materialized view {:?} has no live table",
15514                view.name
15515            ))
15516        })?;
15517        if view.name.is_empty()
15518            || view.query.trim().is_empty()
15519            || view.last_refresh_epoch > catalog.db_epoch
15520            || !views.insert(view.name.as_str())
15521        {
15522            return Err(MongrelError::Schema(format!(
15523                "materialized view {:?} has no unique live table",
15524                view.name
15525            )));
15526        }
15527        if let Some(incremental) = &view.incremental {
15528            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
15529                MongrelError::Schema(format!(
15530                    "materialized view {:?} references missing source {:?}",
15531                    view.name, incremental.source_table
15532                ))
15533            })?;
15534            if source.table_id != incremental.source_table_id
15535                || source
15536                    .schema
15537                    .columns
15538                    .iter()
15539                    .all(|column| column.id != incremental.group_column)
15540            {
15541                return Err(MongrelError::Schema(format!(
15542                    "materialized view {:?} has invalid incremental source",
15543                    view.name
15544                )));
15545            }
15546            let target_ids = target
15547                .schema
15548                .columns
15549                .iter()
15550                .map(|column| column.id)
15551                .collect::<HashSet<_>>();
15552            let mut output_ids = HashSet::new();
15553            let count_outputs = incremental
15554                .outputs
15555                .iter()
15556                .filter(|output| {
15557                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15558                })
15559                .count();
15560            if incremental.checkpoint_event_id.is_empty()
15561                || !target_ids.contains(&incremental.group_output_column)
15562                || !target_ids.contains(&incremental.count_output_column)
15563                || incremental.outputs.is_empty()
15564                || count_outputs != 1
15565                || incremental.outputs.iter().any(|output| {
15566                    !target_ids.contains(&output.output_column)
15567                        || output.output_column == incremental.group_output_column
15568                        || !output_ids.insert(output.output_column)
15569                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15570                            && output.output_column != incremental.count_output_column
15571                        || match output.kind {
15572                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
15573                                source
15574                                    .schema
15575                                    .columns
15576                                    .iter()
15577                                    .all(|column| column.id != source_column)
15578                            }
15579                            crate::catalog::IncrementalAggregateKind::Count => false,
15580                        }
15581                })
15582            {
15583                return Err(MongrelError::Schema(format!(
15584                    "materialized view {:?} has invalid incremental outputs",
15585                    view.name
15586                )));
15587            }
15588        }
15589    }
15590
15591    validate_security_catalog(catalog, &catalog.security)?;
15592    validate_recovered_auth_catalog(catalog)?;
15593    Ok(())
15594}
15595
15596fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
15597    let mut changed = false;
15598    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
15599        let required = maximum
15600            .checked_add(1)
15601            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15602        if catalog.next_table_id < required {
15603            catalog.next_table_id = required;
15604            changed = true;
15605        }
15606    }
15607    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
15608        let required = maximum
15609            .checked_add(1)
15610            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
15611        if catalog.next_user_id < required {
15612            catalog.next_user_id = required;
15613            changed = true;
15614        }
15615    }
15616    Ok(changed)
15617}
15618
15619fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
15620    for foreign_key in &schema.constraints.foreign_keys {
15621        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
15622            MongrelError::Schema(format!(
15623                "foreign key {:?} references unknown live table {:?}",
15624                foreign_key.name, foreign_key.ref_table
15625            ))
15626        })?;
15627        let referenced_unique = parent
15628            .schema
15629            .constraints
15630            .uniques
15631            .iter()
15632            .any(|unique| unique.columns == foreign_key.ref_columns)
15633            || foreign_key.ref_columns.len() == 1
15634                && parent
15635                    .schema
15636                    .primary_key()
15637                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
15638        if !referenced_unique {
15639            return Err(MongrelError::Schema(format!(
15640                "foreign key {:?} does not reference a unique key",
15641                foreign_key.name
15642            )));
15643        }
15644        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
15645            let local = schema.columns.iter().find(|column| column.id == *local_id);
15646            let referenced = parent
15647                .schema
15648                .columns
15649                .iter()
15650                .find(|column| column.id == *parent_id);
15651            if local
15652                .zip(referenced)
15653                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
15654            {
15655                return Err(MongrelError::Schema(format!(
15656                    "foreign key {:?} has missing or incompatible columns",
15657                    foreign_key.name
15658                )));
15659            }
15660        }
15661    }
15662    Ok(())
15663}
15664
15665fn validate_recovered_procedure_references(
15666    catalog: &Catalog,
15667    procedure: &StoredProcedure,
15668) -> Result<()> {
15669    for step in &procedure.body.steps {
15670        let Some(table_name) = step.table() else {
15671            continue;
15672        };
15673        let schema = &catalog
15674            .live(table_name)
15675            .ok_or_else(|| {
15676                MongrelError::Schema(format!(
15677                    "procedure {:?} references unknown table {table_name:?}",
15678                    procedure.name
15679                ))
15680            })?
15681            .schema;
15682        match step {
15683            ProcedureStep::NativeQuery {
15684                conditions,
15685                projection,
15686                ..
15687            } => {
15688                for condition in conditions {
15689                    validate_condition_columns(condition, schema)?;
15690                }
15691                for id in projection.iter().flatten() {
15692                    validate_column_id(*id, schema)?;
15693                }
15694            }
15695            ProcedureStep::Put { cells, .. } => {
15696                for cell in cells {
15697                    validate_column_id(cell.column_id, schema)?;
15698                }
15699            }
15700            ProcedureStep::Upsert {
15701                cells,
15702                update_cells,
15703                ..
15704            } => {
15705                for cell in cells.iter().chain(update_cells.iter().flatten()) {
15706                    validate_column_id(cell.column_id, schema)?;
15707                }
15708            }
15709            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
15710                return Err(MongrelError::Schema(format!(
15711                    "procedure {:?} deletes by primary key on table without one",
15712                    procedure.name
15713                )));
15714            }
15715            ProcedureStep::DeleteByPk { .. }
15716            | ProcedureStep::DeleteRows { .. }
15717            | ProcedureStep::SqlQuery { .. } => {}
15718        }
15719    }
15720    Ok(())
15721}
15722
15723fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
15724    let target_schema = match &trigger.target {
15725        TriggerTarget::Table(name) => catalog
15726            .live(name)
15727            .ok_or_else(|| {
15728                MongrelError::Schema(format!(
15729                    "trigger {:?} references unknown table {name:?}",
15730                    trigger.name
15731                ))
15732            })?
15733            .schema
15734            .clone(),
15735        TriggerTarget::View(_) => Schema {
15736            columns: trigger.target_columns.clone(),
15737            ..Schema::default()
15738        },
15739    };
15740    for column in &trigger.update_of {
15741        if target_schema.column(column).is_none() {
15742            return Err(MongrelError::Schema(format!(
15743                "trigger {:?} references unknown UPDATE OF column {column:?}",
15744                trigger.name
15745            )));
15746        }
15747    }
15748    if let Some(expr) = &trigger.when {
15749        validate_trigger_expr(expr, &target_schema, trigger.event)?;
15750    }
15751    let mut selects = HashMap::new();
15752    for step in &trigger.program.steps {
15753        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
15754            return Err(MongrelError::Schema(
15755                "SetNew is only valid in BEFORE triggers".into(),
15756            ));
15757        }
15758        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
15759    }
15760    Ok(())
15761}
15762
15763fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
15764    let mut role_names = HashSet::new();
15765    for role in &catalog.roles {
15766        if role.name.is_empty()
15767            || role.created_epoch > catalog.db_epoch
15768            || !role_names.insert(role.name.as_str())
15769        {
15770            return Err(MongrelError::Schema(format!(
15771                "invalid or duplicate role {:?}",
15772                role.name
15773            )));
15774        }
15775        for permission in &role.permissions {
15776            if let Some(table) = permission_table(permission) {
15777                let schema = catalog
15778                    .live(table)
15779                    .map(|entry| &entry.schema)
15780                    .or_else(|| {
15781                        catalog
15782                            .external_tables
15783                            .iter()
15784                            .find(|entry| entry.name == table)
15785                            .map(|entry| &entry.declared_schema)
15786                    })
15787                    .ok_or_else(|| {
15788                        MongrelError::Schema(format!(
15789                            "role {:?} references unknown table {table:?}",
15790                            role.name
15791                        ))
15792                    })?;
15793                let columns = match permission {
15794                    crate::auth::Permission::SelectColumns { columns, .. }
15795                    | crate::auth::Permission::InsertColumns { columns, .. }
15796                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
15797                    _ => None,
15798                };
15799                if columns.is_some_and(|columns| {
15800                    columns.is_empty()
15801                        || columns.iter().any(|column| schema.column(column).is_none())
15802                }) {
15803                    return Err(MongrelError::Schema(format!(
15804                        "role {:?} contains invalid column permissions",
15805                        role.name
15806                    )));
15807                }
15808            }
15809        }
15810    }
15811    let mut user_ids = HashSet::new();
15812    let mut usernames = HashSet::new();
15813    let mut maximum_user_id = 0;
15814    for user in &catalog.users {
15815        maximum_user_id = maximum_user_id.max(user.id);
15816        if user.id == 0
15817            || user.username.is_empty()
15818            || user.password_hash.is_empty()
15819            || user.created_epoch > catalog.db_epoch
15820            || !user_ids.insert(user.id)
15821            || !usernames.insert(user.username.as_str())
15822            || user
15823                .roles
15824                .iter()
15825                .any(|role| !role_names.contains(role.as_str()))
15826        {
15827            return Err(MongrelError::Schema(format!(
15828                "invalid or duplicate user {:?}",
15829                user.username
15830            )));
15831        }
15832    }
15833    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
15834        return Err(MongrelError::Schema(
15835            "catalog next_user_id does not advance beyond existing user ids".into(),
15836        ));
15837    }
15838    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
15839        return Err(MongrelError::Schema(
15840            "authenticated catalog has no administrator".into(),
15841        ));
15842    }
15843    Ok(())
15844}
15845
15846fn validate_recovered_storage_plan(
15847    root: &Path,
15848    durable_root: Option<&crate::durable_file::DurableRoot>,
15849    catalog: &Catalog,
15850    created_table_ids: &HashSet<u64>,
15851    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
15852    meta_dek: Option<&[u8; META_DEK_LEN]>,
15853) -> Result<Vec<u64>> {
15854    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
15855    let mut reconcile = Vec::new();
15856    for entry in &catalog.tables {
15857        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15858            continue;
15859        }
15860        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15861        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
15862        let table_exists = match durable_root {
15863            Some(root) => match root.open_directory(&relative_dir) {
15864                Ok(_) => true,
15865                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15866                Err(error) => return Err(error.into()),
15867            },
15868            None => table_dir.is_dir(),
15869        };
15870        if !table_exists {
15871            if created_table_ids.contains(&entry.table_id) {
15872                reconcile.push(entry.table_id);
15873                continue;
15874            }
15875            return Err(MongrelError::NotFound(format!(
15876                "catalog table {} storage is missing",
15877                entry.table_id
15878            )));
15879        }
15880        let manifest_result = match durable_root {
15881            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
15882            None => crate::manifest::read(&table_dir, meta_dek),
15883        };
15884        let manifest = match manifest_result {
15885            Ok(manifest) => manifest,
15886            Err(MongrelError::Io(error))
15887                if created_table_ids.contains(&entry.table_id)
15888                    && error.kind() == std::io::ErrorKind::NotFound =>
15889            {
15890                reconcile.push(entry.table_id);
15891                continue;
15892            }
15893            Err(error) => return Err(error),
15894        };
15895        if manifest.table_id != entry.table_id {
15896            return Err(MongrelError::Conflict(format!(
15897                "catalog table {} storage identity mismatch",
15898                entry.table_id
15899            )));
15900        }
15901        let schema_result = match durable_root {
15902            Some(root) => root
15903                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
15904                .map_err(MongrelError::from),
15905            None => crate::durable_file::open_regular_nofollow(
15906                &table_dir.join(crate::engine::SCHEMA_FILENAME),
15907            ),
15908        };
15909        let file = match schema_result {
15910            Ok(file) => file,
15911            Err(MongrelError::Io(error))
15912                if created_table_ids.contains(&entry.table_id)
15913                    && error.kind() == std::io::ErrorKind::NotFound =>
15914            {
15915                reconcile.push(entry.table_id);
15916                continue;
15917            }
15918            Err(error) => return Err(error),
15919        };
15920        let length = file.metadata()?.len();
15921        if length > MAX_SCHEMA_BYTES {
15922            return Err(MongrelError::ResourceLimitExceeded {
15923                resource: "recovered schema bytes",
15924                requested: usize::try_from(length).unwrap_or(usize::MAX),
15925                limit: MAX_SCHEMA_BYTES as usize,
15926            });
15927        }
15928        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
15929            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
15930        if manifest.schema_id != entry.schema.schema_id
15931            || crate::wal::DdlOp::encode_schema(&disk_schema)?
15932                != crate::wal::DdlOp::encode_schema(&entry.schema)?
15933        {
15934            reconcile.push(entry.table_id);
15935        }
15936    }
15937    for table_id in ttl_updates.keys() {
15938        if !catalog.tables.iter().any(|entry| {
15939            entry.table_id == *table_id
15940                && matches!(entry.state, TableState::Live | TableState::Building { .. })
15941        }) {
15942            continue;
15943        }
15944        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
15945        let table_exists = match durable_root {
15946            Some(root) => match root.open_directory(&relative_dir) {
15947                Ok(_) => true,
15948                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15949                Err(error) => return Err(error.into()),
15950            },
15951            None => root.join(&relative_dir).is_dir(),
15952        };
15953        if !table_exists && !created_table_ids.contains(table_id) {
15954            return Err(MongrelError::NotFound(format!(
15955                "TTL recovery table {table_id} storage is missing"
15956            )));
15957        }
15958    }
15959    reconcile.sort_unstable();
15960    reconcile.dedup();
15961    Ok(reconcile)
15962}
15963
15964fn validate_catalog_table_storage(
15965    root: &crate::durable_file::DurableRoot,
15966    catalog: &Catalog,
15967    meta_dek: Option<&[u8; META_DEK_LEN]>,
15968) -> Result<()> {
15969    for entry in &catalog.tables {
15970        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15971            continue;
15972        }
15973        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15974        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
15975        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
15976            return Err(MongrelError::Conflict(format!(
15977                "catalog table {} storage identity mismatch",
15978                entry.table_id
15979            )));
15980        }
15981        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
15982    }
15983    Ok(())
15984}
15985
15986fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
15987    match schema.columns.iter_mut().find(|c| c.id == column.id) {
15988        Some(existing) if *existing == column => Ok(false),
15989        Some(existing) => {
15990            *existing = column;
15991            schema.schema_id = schema
15992                .schema_id
15993                .checked_add(1)
15994                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15995            Ok(true)
15996        }
15997        None => {
15998            schema.columns.push(column);
15999            schema.schema_id = schema
16000                .schema_id
16001                .checked_add(1)
16002                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
16003            Ok(true)
16004        }
16005    }
16006}
16007
16008fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
16009    use crate::auth::Permission;
16010    match permission {
16011        Permission::Select { table }
16012        | Permission::Insert { table }
16013        | Permission::Update { table }
16014        | Permission::Delete { table }
16015        | Permission::SelectColumns { table, .. }
16016        | Permission::InsertColumns { table, .. }
16017        | Permission::UpdateColumns { table, .. } => Some(table),
16018        Permission::All | Permission::Ddl | Permission::Admin => None,
16019    }
16020}
16021
16022fn apply_rebuilding_publish(
16023    catalog: &mut Catalog,
16024    table_id: u64,
16025    replaced_table_id: u64,
16026    new_name: &str,
16027    epoch: u64,
16028) -> Result<bool> {
16029    let already_published = catalog.tables.iter().any(|entry| {
16030        entry.table_id == table_id
16031            && entry.name == new_name
16032            && matches!(entry.state, TableState::Live)
16033    }) && catalog.tables.iter().any(|entry| {
16034        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
16035    });
16036    if already_published {
16037        return Ok(false);
16038    }
16039    let schema = catalog
16040        .tables
16041        .iter()
16042        .find(|entry| entry.table_id == table_id)
16043        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
16044        .schema
16045        .clone();
16046    let replaced = catalog
16047        .tables
16048        .iter_mut()
16049        .find(|entry| entry.table_id == replaced_table_id)
16050        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
16051    replaced.state = TableState::Dropped { at_epoch: epoch };
16052    let replacement = catalog
16053        .tables
16054        .iter_mut()
16055        .find(|entry| entry.table_id == table_id)
16056        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
16057    replacement.name = new_name.to_string();
16058    replacement.state = TableState::Live;
16059
16060    for role in &mut catalog.roles {
16061        role.permissions.retain_mut(|permission| {
16062            retain_rebuilt_permission_columns(permission, new_name, &schema)
16063        });
16064    }
16065    for definition in &mut catalog.materialized_views {
16066        if let Some(incremental) = definition.incremental.as_mut() {
16067            if incremental.source_table == new_name
16068                && incremental.source_table_id == replaced_table_id
16069            {
16070                incremental.source_table_id = table_id;
16071            }
16072        }
16073    }
16074    advance_security_version(catalog)?;
16075    Ok(true)
16076}
16077
16078fn retain_rebuilt_permission_columns(
16079    permission: &mut crate::auth::Permission,
16080    target_table: &str,
16081    schema: &Schema,
16082) -> bool {
16083    use crate::auth::Permission;
16084    let columns = match permission {
16085        Permission::SelectColumns { table, columns }
16086        | Permission::InsertColumns { table, columns }
16087        | Permission::UpdateColumns { table, columns }
16088            if table == target_table =>
16089        {
16090            Some(columns)
16091        }
16092        _ => None,
16093    };
16094    if let Some(columns) = columns {
16095        columns.retain(|column| schema.column(column).is_some());
16096        !columns.is_empty()
16097    } else {
16098        true
16099    }
16100}
16101
16102fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
16103    use crate::auth::Permission;
16104    let table = match permission {
16105        Permission::Select { table }
16106        | Permission::Insert { table }
16107        | Permission::Update { table }
16108        | Permission::Delete { table }
16109        | Permission::SelectColumns { table, .. }
16110        | Permission::InsertColumns { table, .. }
16111        | Permission::UpdateColumns { table, .. } => Some(table),
16112        Permission::All | Permission::Ddl | Permission::Admin => None,
16113    };
16114    if let Some(table) = table.filter(|table| table.as_str() == old) {
16115        *table = new.to_string();
16116    }
16117}
16118
16119fn rename_permission_column(
16120    permission: &mut crate::auth::Permission,
16121    target_table: &str,
16122    old: &str,
16123    new: &str,
16124) {
16125    use crate::auth::Permission;
16126    let columns = match permission {
16127        Permission::SelectColumns { table, columns }
16128        | Permission::InsertColumns { table, columns }
16129        | Permission::UpdateColumns { table, columns }
16130            if table == target_table =>
16131        {
16132            Some(columns)
16133        }
16134        _ => None,
16135    };
16136    if let Some(column) = columns
16137        .into_iter()
16138        .flatten()
16139        .find(|column| column.as_str() == old)
16140    {
16141        *column = new.to_string();
16142    }
16143}
16144
16145fn merge_permission(
16146    permissions: &mut Vec<crate::auth::Permission>,
16147    permission: crate::auth::Permission,
16148) {
16149    use crate::auth::Permission;
16150    let (kind, table, mut columns) = match permission {
16151        Permission::SelectColumns { table, columns } => (0, table, columns),
16152        Permission::InsertColumns { table, columns } => (1, table, columns),
16153        Permission::UpdateColumns { table, columns } => (2, table, columns),
16154        permission if !permissions.contains(&permission) => {
16155            permissions.push(permission);
16156            return;
16157        }
16158        _ => return,
16159    };
16160    for permission in permissions.iter_mut() {
16161        let existing = match permission {
16162            Permission::SelectColumns {
16163                table: existing_table,
16164                columns,
16165            } if kind == 0 && existing_table == &table => Some(columns),
16166            Permission::InsertColumns {
16167                table: existing_table,
16168                columns,
16169            } if kind == 1 && existing_table == &table => Some(columns),
16170            Permission::UpdateColumns {
16171                table: existing_table,
16172                columns,
16173            } if kind == 2 && existing_table == &table => Some(columns),
16174            _ => None,
16175        };
16176        if let Some(existing) = existing {
16177            existing.append(&mut columns);
16178            existing.sort();
16179            existing.dedup();
16180            return;
16181        }
16182    }
16183    columns.sort();
16184    columns.dedup();
16185    let permission = if kind == 0 {
16186        Permission::SelectColumns { table, columns }
16187    } else if kind == 1 {
16188        Permission::InsertColumns { table, columns }
16189    } else {
16190        Permission::UpdateColumns { table, columns }
16191    };
16192    permissions.push(permission);
16193}
16194
16195fn revoke_permission_from(
16196    permissions: &mut Vec<crate::auth::Permission>,
16197    revoked: &crate::auth::Permission,
16198) {
16199    use crate::auth::Permission;
16200    let revoked_columns = match revoked {
16201        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
16202        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
16203        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
16204        _ => None,
16205    };
16206    let Some((kind, table, columns)) = revoked_columns else {
16207        permissions.retain(|permission| permission != revoked);
16208        return;
16209    };
16210    for permission in permissions.iter_mut() {
16211        let current = match permission {
16212            Permission::SelectColumns {
16213                table: current_table,
16214                columns,
16215            } if kind == 0 && current_table == table => Some(columns),
16216            Permission::InsertColumns {
16217                table: current_table,
16218                columns,
16219            } if kind == 1 && current_table == table => Some(columns),
16220            Permission::UpdateColumns {
16221                table: current_table,
16222                columns,
16223            } if kind == 2 && current_table == table => Some(columns),
16224            _ => None,
16225        };
16226        if let Some(current) = current {
16227            current.retain(|column| !columns.contains(column));
16228        }
16229    }
16230    permissions.retain(|permission| match permission {
16231        Permission::SelectColumns { columns, .. }
16232        | Permission::InsertColumns { columns, .. }
16233        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
16234        _ => true,
16235    });
16236}
16237
16238fn validate_security_catalog(
16239    catalog: &Catalog,
16240    security: &crate::security::SecurityCatalog,
16241) -> Result<()> {
16242    let mut policy_names = HashSet::new();
16243    for table in &security.rls_tables {
16244        if catalog.live(table).is_none() {
16245            return Err(MongrelError::NotFound(format!(
16246                "RLS table {table:?} not found"
16247            )));
16248        }
16249    }
16250    for policy in &security.policies {
16251        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
16252            return Err(MongrelError::InvalidArgument(format!(
16253                "duplicate policy {:?} on {:?}",
16254                policy.name, policy.table
16255            )));
16256        }
16257        let schema = &catalog
16258            .live(&policy.table)
16259            .ok_or_else(|| {
16260                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
16261            })?
16262            .schema;
16263        if let Some(expression) = &policy.using {
16264            validate_security_expression(expression, schema)?;
16265        }
16266        if let Some(expression) = &policy.with_check {
16267            validate_security_expression(expression, schema)?;
16268        }
16269    }
16270    let mut mask_names = HashSet::new();
16271    for mask in &security.masks {
16272        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
16273            return Err(MongrelError::InvalidArgument(format!(
16274                "duplicate mask {:?} on {:?}",
16275                mask.name, mask.table
16276            )));
16277        }
16278        let column = catalog
16279            .live(&mask.table)
16280            .and_then(|entry| {
16281                entry
16282                    .schema
16283                    .columns
16284                    .iter()
16285                    .find(|column| column.id == mask.column)
16286            })
16287            .ok_or_else(|| {
16288                MongrelError::NotFound(format!(
16289                    "mask column {} on {:?} not found",
16290                    mask.column, mask.table
16291                ))
16292            })?;
16293        if matches!(
16294            mask.strategy,
16295            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
16296        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
16297        {
16298            return Err(MongrelError::InvalidArgument(format!(
16299                "mask {:?} requires a string/bytes column",
16300                mask.name
16301            )));
16302        }
16303    }
16304    Ok(())
16305}
16306
16307fn validate_security_expression(
16308    expression: &crate::security::SecurityExpr,
16309    schema: &Schema,
16310) -> Result<()> {
16311    use crate::security::SecurityExpr;
16312    match expression {
16313        SecurityExpr::True => Ok(()),
16314        SecurityExpr::ColumnEqCurrentUser { column }
16315        | SecurityExpr::ColumnEqValue { column, .. } => {
16316            if schema
16317                .columns
16318                .iter()
16319                .any(|candidate| candidate.id == *column)
16320            {
16321                Ok(())
16322            } else {
16323                Err(MongrelError::InvalidArgument(format!(
16324                    "security expression references unknown column id {column}"
16325                )))
16326            }
16327        }
16328        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
16329            validate_security_expression(left, schema)?;
16330            validate_security_expression(right, schema)
16331        }
16332        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
16333    }
16334}
16335
16336/// Remove canonical numeric table directories that no catalog generation owns.
16337fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
16338    let referenced = cat
16339        .tables
16340        .iter()
16341        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
16342        .map(|entry| entry.table_id)
16343        .collect::<HashSet<_>>();
16344    let tables_dir = root.join(TABLES_DIR);
16345    let entries = match std::fs::read_dir(&tables_dir) {
16346        Ok(entries) => entries,
16347        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
16348        Err(error) => return Err(error.into()),
16349    };
16350    for entry in entries {
16351        let entry = entry?;
16352        if !entry.file_type()?.is_dir() {
16353            continue;
16354        }
16355        let file_name = entry.file_name();
16356        let Some(name) = file_name.to_str() else {
16357            continue;
16358        };
16359        let Ok(table_id) = name.parse::<u64>() else {
16360            continue;
16361        };
16362        if name != table_id.to_string() {
16363            continue;
16364        }
16365        if !referenced.contains(&table_id) {
16366            crate::durable_file::remove_directory_all(&entry.path())?;
16367        }
16368    }
16369    Ok(())
16370}
16371
16372/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
16373/// #14). These dirs hold pending uniform-epoch runs from large transactions
16374/// that were aborted or crashed before commit. On open, all such dirs are safe
16375/// to remove because committed txns moved their runs to `_runs/` at publish.
16376fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
16377    for entry in &cat.tables {
16378        let txn_dir = root
16379            .join(TABLES_DIR)
16380            .join(entry.table_id.to_string())
16381            .join("_txn");
16382        if txn_dir.exists() {
16383            let _ = std::fs::remove_dir_all(&txn_dir);
16384        }
16385    }
16386}
16387
16388#[cfg(test)]
16389mod write_permission_tests {
16390    use super::*;
16391    use crate::txn::Staged;
16392
16393    struct NoopExternalBridge;
16394
16395    impl ExternalTriggerBridge for NoopExternalBridge {
16396        fn apply_trigger_external_write(
16397            &self,
16398            _entry: &ExternalTableEntry,
16399            base_state: Vec<u8>,
16400            _op: ExternalTriggerWrite,
16401        ) -> Result<ExternalTriggerWriteResult> {
16402            Ok(ExternalTriggerWriteResult::new(base_state))
16403        }
16404    }
16405
16406    fn assert_txn_namespace_full<T>(result: Result<T>) {
16407        assert!(matches!(result, Err(MongrelError::Full(_))));
16408    }
16409
16410    #[test]
16411    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
16412        let directory = tempfile::tempdir().unwrap();
16413        let database = Database::create(directory.path()).unwrap();
16414        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
16415        *database.next_txn_id.lock() = generation << 32;
16416        let before = crate::wal::SharedWal::replay(directory.path())
16417            .unwrap()
16418            .len();
16419        let bridge = NoopExternalBridge;
16420
16421        assert_txn_namespace_full(database.begin().commit());
16422        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
16423        assert_txn_namespace_full(
16424            database
16425                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
16426                .commit(),
16427        );
16428        assert_txn_namespace_full(
16429            database
16430                .begin_with_external_trigger_bridge(&bridge)
16431                .commit(),
16432        );
16433        assert_txn_namespace_full(
16434            database
16435                .begin_with_external_trigger_bridge_as(&bridge, None)
16436                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
16437        );
16438
16439        assert_eq!(
16440            crate::wal::SharedWal::replay(directory.path())
16441                .unwrap()
16442                .len(),
16443            before
16444        );
16445        drop(database);
16446        Database::open(directory.path()).unwrap();
16447    }
16448
16449    #[test]
16450    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
16451        let directory = tempfile::tempdir().unwrap();
16452        let table_dir = directory.path().join("7");
16453        crate::durable_file::create_directory_all(&table_dir).unwrap();
16454        let original_schema = test_schema();
16455        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
16456        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
16457        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
16458        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
16459        let original_bytes = std::fs::read(&schema_path).unwrap();
16460
16461        let mut replacement_schema = original_schema;
16462        replacement_schema.schema_id += 1;
16463        assert!(matches!(
16464            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
16465            Err(MongrelError::Conflict(_))
16466        ));
16467
16468        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
16469        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
16470        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
16471        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
16472    }
16473
16474    #[test]
16475    fn catalog_table_missing_storage_fails_without_recreating_it() {
16476        let directory = tempfile::tempdir().unwrap();
16477        let table_dir = {
16478            let database = Database::create(directory.path()).unwrap();
16479            database.create_table("docs", test_schema()).unwrap();
16480            directory
16481                .path()
16482                .join(TABLES_DIR)
16483                .join(database.table_id("docs").unwrap().to_string())
16484        };
16485        std::fs::remove_dir_all(&table_dir).unwrap();
16486
16487        assert!(matches!(
16488            Database::open(directory.path()),
16489            Err(MongrelError::NotFound(_))
16490        ));
16491        assert!(!table_dir.exists());
16492    }
16493
16494    #[test]
16495    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
16496        let directory = tempfile::tempdir().unwrap();
16497        let database = std::sync::Arc::new(
16498            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
16499        );
16500        database.create_user("alice", "old-password").unwrap();
16501        let old_identity = database.user_identity("alice").unwrap();
16502        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
16503        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
16504        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
16505        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
16506
16507        std::thread::scope(|scope| {
16508            let authenticate = {
16509                let database = std::sync::Arc::clone(&database);
16510                scope.spawn(move || {
16511                    database.authenticate_principal_inner("alice", "old-password", || {
16512                        verified_tx.send(()).unwrap();
16513                        resume_rx.recv().unwrap();
16514                    })
16515                })
16516            };
16517            verified_rx.recv().unwrap();
16518            let mutate = {
16519                let database = std::sync::Arc::clone(&database);
16520                scope.spawn(move || {
16521                    mutation_started_tx.send(()).unwrap();
16522                    database.drop_user("alice").unwrap();
16523                    database.create_user("alice", "new-password").unwrap();
16524                    mutation_done_tx.send(()).unwrap();
16525                })
16526            };
16527            mutation_started_rx.recv().unwrap();
16528            assert!(mutation_done_rx
16529                .recv_timeout(std::time::Duration::from_millis(50))
16530                .is_err());
16531            resume_tx.send(()).unwrap();
16532            let principal = authenticate.join().unwrap().unwrap().unwrap();
16533            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
16534            mutate.join().unwrap();
16535        });
16536
16537        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
16538        assert!(database
16539            .authenticate_principal("alice", "old-password")
16540            .unwrap()
16541            .is_none());
16542        assert!(database
16543            .authenticate_principal("alice", "new-password")
16544            .unwrap()
16545            .is_some());
16546    }
16547
16548    #[test]
16549    fn homogeneous_batch_summarizes_to_one_permission_decision() {
16550        let staging = (0..10_050)
16551            .map(|_| {
16552                (
16553                    7,
16554                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
16555                )
16556            })
16557            .collect::<Vec<_>>();
16558
16559        let needs = summarize_write_permissions(&staging);
16560        let table = needs.get(&7).unwrap();
16561        assert_eq!(needs.len(), 1);
16562        assert!(table.insert);
16563        assert_eq!(table.insert_columns, [1, 2]);
16564        assert!(!table.update);
16565        assert!(!table.delete);
16566        assert!(!table.truncate);
16567    }
16568
16569    #[test]
16570    fn mixed_writes_union_columns_and_preserve_empty_operations() {
16571        let staging = vec![
16572            (7, Staged::Put(vec![(2, Value::Int64(2))])),
16573            (7, Staged::Put(vec![(1, Value::Int64(1))])),
16574            (
16575                7,
16576                Staged::Update {
16577                    row_id: RowId(1),
16578                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
16579                    changed_columns: vec![2],
16580                },
16581            ),
16582            (7, Staged::Delete(RowId(2))),
16583            (8, Staged::Truncate),
16584        ];
16585
16586        let needs = summarize_write_permissions(&staging);
16587        let table = needs.get(&7).unwrap();
16588        assert_eq!(table.insert_columns, [1, 2]);
16589        assert!(table.update);
16590        assert_eq!(table.update_columns, [2]);
16591        assert!(table.delete);
16592        assert!(needs.get(&8).unwrap().truncate);
16593    }
16594
16595    #[test]
16596    fn final_permission_decisions_do_not_scale_with_rows() {
16597        let credentialless_dir = tempfile::tempdir().unwrap();
16598        let credentialless = Database::create(credentialless_dir.path()).unwrap();
16599        credentialless.create_table("docs", test_schema()).unwrap();
16600        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16601        credentialless
16602            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
16603            .unwrap();
16604        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
16605
16606        let authenticated_dir = tempfile::tempdir().unwrap();
16607        let authenticated =
16608            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
16609                .unwrap();
16610        authenticated.create_table("docs", test_schema()).unwrap();
16611        let admin = authenticated.resolve_principal("admin").unwrap();
16612        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16613        authenticated
16614            .validate_write_permissions(
16615                &puts(authenticated.table_id("docs").unwrap()),
16616                Some(&admin),
16617                None,
16618            )
16619            .unwrap();
16620        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16621    }
16622
16623    #[test]
16624    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
16625        let dir = tempfile::tempdir().unwrap();
16626        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16627        db.create_table("docs", test_schema()).unwrap();
16628        let admin = db.resolve_principal("admin").unwrap();
16629        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16630
16631        let mut transaction = db.begin_as(Some(admin));
16632        transaction
16633            .delete_batch("docs", (0..100).map(RowId).collect())
16634            .unwrap();
16635        transaction.commit().unwrap();
16636
16637        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
16638    }
16639
16640    #[test]
16641    fn truncate_validation_checks_admin_once_for_all_tables() {
16642        let dir = tempfile::tempdir().unwrap();
16643        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16644        db.create_table("first", test_schema()).unwrap();
16645        db.create_table("second", test_schema()).unwrap();
16646        let admin = db.resolve_principal("admin").unwrap();
16647        let staging = vec![
16648            (db.table_id("first").unwrap(), Staged::Truncate),
16649            (db.table_id("second").unwrap(), Staged::Truncate),
16650        ];
16651
16652        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16653        db.validate_write_permissions(&staging, Some(&admin), None)
16654            .unwrap();
16655        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16656    }
16657
16658    #[test]
16659    fn one_table_commit_batches_structural_work() {
16660        let dir = tempfile::tempdir().unwrap();
16661        let db = Database::create(dir.path()).unwrap();
16662        db.create_table("docs", test_schema()).unwrap();
16663        let table_id = db.table_id("docs").unwrap();
16664
16665        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
16666        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16667        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16668        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16669        db.transaction(|transaction| {
16670            for id in 0..100 {
16671                transaction.put("docs", vec![(1, Value::Int64(id))])?;
16672            }
16673            Ok(())
16674        })
16675        .unwrap();
16676
16677        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
16678        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16679        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16680        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16681
16682        let puts = crate::wal::SharedWal::replay(dir.path())
16683            .unwrap()
16684            .into_iter()
16685            .filter_map(|record| match record.op {
16686                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
16687                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
16688                        .unwrap()
16689                        .len(),
16690                ),
16691                _ => None,
16692            })
16693            .collect::<Vec<_>>();
16694        assert_eq!(puts, [100]);
16695
16696        let row_ids = db
16697            .table("docs")
16698            .unwrap()
16699            .lock()
16700            .visible_rows(db.snapshot().0)
16701            .unwrap()
16702            .into_iter()
16703            .take(2)
16704            .map(|row| row.row_id)
16705            .collect::<Vec<_>>();
16706        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16707        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16708        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16709        db.transaction(|transaction| {
16710            for row_id in row_ids {
16711                transaction.delete("docs", row_id)?;
16712            }
16713            Ok(())
16714        })
16715        .unwrap();
16716        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16717        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16718        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16719
16720        let deletes = crate::wal::SharedWal::replay(dir.path())
16721            .unwrap()
16722            .into_iter()
16723            .filter_map(|record| match record.op {
16724                crate::wal::Op::Delete {
16725                    table_id: id,
16726                    row_ids,
16727                } if id == table_id => Some(row_ids.len()),
16728                _ => None,
16729            })
16730            .collect::<Vec<_>>();
16731        assert_eq!(deletes, [2]);
16732    }
16733
16734    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
16735        (0..10_050)
16736            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
16737            .collect()
16738    }
16739
16740    fn test_schema() -> Schema {
16741        Schema {
16742            columns: vec![ColumnDef {
16743                id: 1,
16744                name: "id".into(),
16745                ty: TypeId::Int64,
16746                flags: crate::schema::ColumnFlags::empty()
16747                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
16748                default_value: None,
16749            }],
16750            ..Schema::default()
16751        }
16752    }
16753}
16754
16755#[cfg(test)]
16756mod cdc_bounds_tests {
16757    use super::*;
16758
16759    #[test]
16760    fn retained_byte_limit_rejects_without_allocating_payload() {
16761        let mut retained = 0;
16762        let error = charge_cdc_bytes(
16763            &mut retained,
16764            CDC_MAX_RETAINED_BYTES.saturating_add(1),
16765            "CDC retained bytes",
16766        )
16767        .unwrap_err();
16768        assert!(matches!(
16769            error,
16770            MongrelError::ResourceLimitExceeded {
16771                resource: "CDC retained bytes",
16772                ..
16773            }
16774        ));
16775    }
16776
16777    #[test]
16778    fn row_json_estimate_accounts_for_byte_array_expansion() {
16779        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
16780            .with_column(1, Value::Bytes(vec![0; 1024]));
16781        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
16782    }
16783}
16784
16785#[cfg(test)]
16786mod generation_metrics_tests {
16787    use super::*;
16788    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
16789
16790    #[test]
16791    fn legacy_cow_fallback_is_measured() {
16792        let dir = tempfile::tempdir().unwrap();
16793        let table = Table::create(
16794            dir.path(),
16795            Schema {
16796                columns: vec![ColumnDef {
16797                    id: 1,
16798                    name: "id".into(),
16799                    ty: TypeId::Int64,
16800                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
16801                    default_value: None,
16802                }],
16803                ..Schema::default()
16804            },
16805            1,
16806        )
16807        .unwrap();
16808        let handle = TableHandle::from_table(table);
16809        let held = match &handle.inner {
16810            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
16811            TableHandleInner::Direct(_) => unreachable!(),
16812        };
16813
16814        handle.lock().set_sync_byte_threshold(1);
16815
16816        let stats = handle.generation_stats();
16817        assert_eq!(stats.cow_clone_count, 1);
16818        assert!(stats.estimated_cow_clone_bytes > 0);
16819        drop(held);
16820    }
16821}
16822
16823#[cfg(test)]
16824mod trigger_engine_tests {
16825    use super::*;
16826
16827    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
16828        WriteEvent {
16829            table: "test".into(),
16830            kind: TriggerEvent::Insert,
16831            new: Some(TriggerRowImage {
16832                columns: new_cells.iter().cloned().collect(),
16833            }),
16834            old: Some(TriggerRowImage {
16835                columns: old_cells.iter().cloned().collect(),
16836            }),
16837            changed_columns: Vec::new(),
16838            op_indices: Vec::new(),
16839            put_idx: None,
16840            trigger_stack: Vec::new(),
16841        }
16842    }
16843
16844    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
16845        WriteEvent {
16846            table: "test".into(),
16847            kind: TriggerEvent::Insert,
16848            new: Some(TriggerRowImage {
16849                columns: new_cells.iter().cloned().collect(),
16850            }),
16851            old: None,
16852            changed_columns: Vec::new(),
16853            op_indices: Vec::new(),
16854            put_idx: None,
16855            trigger_stack: Vec::new(),
16856        }
16857    }
16858
16859    #[test]
16860    fn value_order_int64_vs_float64() {
16861        assert_eq!(
16862            value_order(&Value::Int64(5), &Value::Float64(5.0)),
16863            Some(std::cmp::Ordering::Equal)
16864        );
16865        assert_eq!(
16866            value_order(&Value::Int64(5), &Value::Float64(3.0)),
16867            Some(std::cmp::Ordering::Greater)
16868        );
16869        assert_eq!(
16870            value_order(&Value::Int64(2), &Value::Float64(3.0)),
16871            Some(std::cmp::Ordering::Less)
16872        );
16873    }
16874
16875    #[test]
16876    fn value_order_null_returns_none() {
16877        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
16878        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
16879        assert_eq!(value_order(&Value::Null, &Value::Null), None);
16880    }
16881
16882    #[test]
16883    fn value_order_cross_group_returns_none() {
16884        assert_eq!(
16885            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
16886            None
16887        );
16888        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
16889        assert_eq!(
16890            value_order(
16891                &Value::Embedding(vec![1.0, 2.0]),
16892                &Value::Embedding(vec![1.0, 2.0])
16893            ),
16894            None
16895        );
16896    }
16897
16898    #[test]
16899    fn eval_trigger_expr_ranges_and_booleans() {
16900        let expr = TriggerExpr::And {
16901            left: Box::new(TriggerExpr::Gt {
16902                left: TriggerValue::NewColumn(1),
16903                right: TriggerValue::Literal(Value::Int64(0)),
16904            }),
16905            right: Box::new(TriggerExpr::Lte {
16906                left: TriggerValue::NewColumn(1),
16907                right: TriggerValue::Literal(Value::Int64(100)),
16908            }),
16909        };
16910        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
16911        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
16912        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
16913
16914        let or_expr = TriggerExpr::Or {
16915            left: Box::new(TriggerExpr::Lt {
16916                left: TriggerValue::NewColumn(1),
16917                right: TriggerValue::Literal(Value::Int64(0)),
16918            }),
16919            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
16920                TriggerValue::OldColumn(2),
16921            )))),
16922        };
16923        assert!(eval_trigger_expr(
16924            &or_expr,
16925            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
16926        )
16927        .unwrap());
16928        assert!(!eval_trigger_expr(
16929            &or_expr,
16930            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
16931        )
16932        .unwrap());
16933
16934        assert!(eval_trigger_expr(
16935            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
16936            &event_insert(&[])
16937        )
16938        .unwrap());
16939        assert!(!eval_trigger_expr(
16940            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
16941            &event_insert(&[])
16942        )
16943        .unwrap());
16944        assert!(!eval_trigger_expr(
16945            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
16946            &event_insert(&[])
16947        )
16948        .unwrap());
16949    }
16950}