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, 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::Write;
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize};
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";
38
39/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
40/// than any table. `u64::MAX` is never allocated to a real table (the catalog
41/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
42pub const WAL_TABLE_ID: u64 = u64::MAX;
43/// Sentinel `table_id` for `CheckIssue`s that concern external-table module
44/// state instead of an ordinary table.
45pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
46
47fn current_unix_nanos() -> u64 {
48    std::time::SystemTime::now()
49        .duration_since(std::time::UNIX_EPOCH)
50        .unwrap_or_default()
51        .as_nanos() as u64
52}
53
54fn read_history_retention(root: &Path, current_epoch: Epoch) -> Result<(u64, Epoch)> {
55    let path = root.join(META_DIR).join(HISTORY_RETENTION_FILENAME);
56    let text = match std::fs::read_to_string(path) {
57        Ok(text) => text,
58        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
59            return Ok((0, current_epoch));
60        }
61        Err(error) => return Err(error.into()),
62    };
63    let mut fields = text.split_whitespace();
64    let epochs = fields
65        .next()
66        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
67        .parse::<u64>()
68        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
69    let start = fields
70        .next()
71        .unwrap_or("0")
72        .parse::<u64>()
73        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
74    Ok((epochs, Epoch(start)))
75}
76
77fn write_history_retention(root: &Path, epochs: u64, start: Epoch) -> Result<()> {
78    let meta = root.join(META_DIR);
79    std::fs::create_dir_all(&meta)?;
80    let path = meta.join(HISTORY_RETENTION_FILENAME);
81    let tmp = meta.join(format!("{HISTORY_RETENTION_FILENAME}.tmp"));
82    {
83        let mut file = std::fs::File::create(&tmp)?;
84        writeln!(file, "{epochs} {}", start.0)?;
85        file.sync_all()?;
86    }
87    std::fs::rename(tmp, path)?;
88    if let Ok(dir) = std::fs::File::open(meta) {
89        let _ = dir.sync_all();
90    }
91    Ok(())
92}
93
94fn prepare_backup_destination(
95    source: &Path,
96    destination: &Path,
97) -> Result<(PathBuf, PathBuf, PathBuf)> {
98    let source = source.canonicalize()?;
99    if destination.exists() {
100        return Err(MongrelError::Conflict(format!(
101            "backup destination already exists: {}",
102            destination.display()
103        )));
104    }
105    let name = destination
106        .file_name()
107        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?;
108    let requested_parent = destination
109        .parent()
110        .filter(|path| !path.as_os_str().is_empty())
111        .unwrap_or_else(|| Path::new("."));
112    std::fs::create_dir_all(requested_parent)?;
113    let parent = requested_parent.canonicalize()?;
114    if parent.starts_with(&source) {
115        return Err(MongrelError::InvalidArgument(
116            "backup destination must not be inside the source database".into(),
117        ));
118    }
119    let destination = parent.join(name);
120    let nonce = std::time::SystemTime::now()
121        .duration_since(std::time::UNIX_EPOCH)
122        .unwrap_or_default()
123        .as_nanos();
124    let stage = parent.join(format!(
125        ".{}.backup-stage-{}-{nonce}",
126        name.to_string_lossy(),
127        std::process::id()
128    ));
129    if stage.exists() {
130        return Err(MongrelError::Conflict(format!(
131            "backup staging path already exists: {}",
132            stage.display()
133        )));
134    }
135    Ok((destination, parent, stage))
136}
137
138fn copy_backup_boundary(
139    source_root: &Path,
140    destination_root: &Path,
141    deferred_runs: &HashSet<PathBuf>,
142    copied: &mut Vec<PathBuf>,
143) -> Result<()> {
144    fn visit(
145        source_root: &Path,
146        source: &Path,
147        destination_root: &Path,
148        deferred_runs: &HashSet<PathBuf>,
149        copied: &mut Vec<PathBuf>,
150    ) -> Result<()> {
151        let mut entries = std::fs::read_dir(source)?.collect::<std::io::Result<Vec<_>>>()?;
152        entries.sort_by_key(std::fs::DirEntry::file_name);
153        for entry in entries {
154            let path = entry.path();
155            let relative = path
156                .strip_prefix(source_root)
157                .map_err(|error| MongrelError::Other(format!("backup path: {error}")))?;
158            if backup_path_excluded(relative) {
159                continue;
160            }
161            let file_type = entry.file_type()?;
162            if file_type.is_symlink() {
163                return Err(MongrelError::InvalidArgument(format!(
164                    "backup refuses symlink {}",
165                    path.display()
166                )));
167            }
168            if file_type.is_dir() {
169                std::fs::create_dir_all(destination_root.join(relative))?;
170                visit(source_root, &path, destination_root, deferred_runs, copied)?;
171            } else if file_type.is_file() {
172                if deferred_runs.contains(relative) {
173                    continue;
174                }
175                if relative
176                    .parent()
177                    .and_then(Path::file_name)
178                    .is_some_and(|parent| parent == "_runs")
179                    && relative
180                        .extension()
181                        .is_some_and(|extension| extension == "sr")
182                {
183                    continue;
184                }
185                crate::backup::copy_file_synced(&path, &destination_root.join(relative))?;
186                copied.push(relative.to_path_buf());
187            }
188        }
189        Ok(())
190    }
191
192    std::fs::create_dir_all(destination_root)?;
193    visit(
194        source_root,
195        source_root,
196        destination_root,
197        deferred_runs,
198        copied,
199    )
200}
201
202fn backup_path_excluded(relative: &Path) -> bool {
203    relative == Path::new("_meta/.lock")
204        || relative == Path::new("_meta/replica")
205        || relative == Path::new("_meta/repl_epoch")
206        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
207        || relative.components().any(|component| {
208            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
209        })
210}
211
212#[derive(Debug, Clone)]
213pub enum ExternalTriggerWrite {
214    Insert {
215        table: String,
216        cells: Vec<(u16, Value)>,
217    },
218    UpdateByPk {
219        table: String,
220        pk: Value,
221        cells: Vec<(u16, Value)>,
222    },
223    DeleteByPk {
224        table: String,
225        pk: Value,
226    },
227}
228
229impl ExternalTriggerWrite {
230    fn table(&self) -> &str {
231        match self {
232            Self::Insert { table, .. }
233            | Self::UpdateByPk { table, .. }
234            | Self::DeleteByPk { table, .. } => table,
235        }
236    }
237}
238
239#[derive(Debug, Clone, PartialEq)]
240pub enum ExternalTriggerBaseWrite {
241    Put {
242        table: String,
243        cells: Vec<(u16, Value)>,
244    },
245    Delete {
246        table: String,
247        row_id: RowId,
248    },
249}
250
251#[derive(Debug, Clone, PartialEq)]
252pub struct ExternalTriggerWriteResult {
253    pub state: Vec<u8>,
254    pub base_writes: Vec<ExternalTriggerBaseWrite>,
255}
256
257impl ExternalTriggerWriteResult {
258    pub fn new(state: Vec<u8>) -> Self {
259        Self {
260            state,
261            base_writes: Vec::new(),
262        }
263    }
264}
265
266pub trait ExternalTriggerBridge {
267    fn apply_trigger_external_write(
268        &self,
269        entry: &ExternalTableEntry,
270        base_state: Vec<u8>,
271        op: ExternalTriggerWrite,
272    ) -> Result<ExternalTriggerWriteResult>;
273}
274
275/// A pending uniform-epoch run written during a large transaction (spec §8.5).
276struct SpilledRun {
277    table_id: u64,
278    run_id: u128,
279    pending_path: PathBuf,
280    rows: Vec<crate::memtable::Row>,
281    row_count: u64,
282    min_rid: u64,
283    max_rid: u64,
284}
285
286#[derive(Debug, Clone)]
287struct TriggerRowImage {
288    columns: HashMap<u16, Value>,
289}
290
291impl TriggerRowImage {
292    fn from_row(row: crate::memtable::Row) -> Self {
293        Self {
294            columns: row.columns,
295        }
296    }
297
298    fn from_cells(cells: &[(u16, Value)]) -> Self {
299        Self {
300            columns: cells.iter().cloned().collect(),
301        }
302    }
303}
304
305#[derive(Debug, Clone)]
306struct WriteEvent {
307    table: String,
308    kind: TriggerEvent,
309    old: Option<TriggerRowImage>,
310    new: Option<TriggerRowImage>,
311    changed_columns: Vec<u16>,
312    op_indices: Vec<usize>,
313    put_idx: Option<usize>,
314    trigger_stack: Vec<String>,
315}
316
317#[derive(Default)]
318struct TriggerExpansion {
319    before: Vec<(u64, crate::txn::Staged)>,
320    before_stacks: Vec<Vec<String>>,
321    before_external: Vec<ExternalTriggerWrite>,
322    after: Vec<(u64, crate::txn::Staged)>,
323    after_stacks: Vec<Vec<String>>,
324    after_external: Vec<ExternalTriggerWrite>,
325    ignored_indices: std::collections::BTreeSet<usize>,
326}
327
328struct TriggerProgramOutput<'a> {
329    added: &'a mut Vec<(u64, crate::txn::Staged)>,
330    added_stacks: &'a mut Vec<Vec<String>>,
331    added_external: &'a mut Vec<ExternalTriggerWrite>,
332    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336enum TriggerProgramOutcome {
337    Continue,
338    Ignore,
339}
340
341/// An integrity issue found by [`Database::check`] (spec §16).
342#[derive(Debug, Clone)]
343pub struct CheckIssue {
344    pub table_id: u64,
345    pub table_name: String,
346    pub severity: String,
347    pub description: String,
348}
349
350/// One optimistic authorization snapshot for a complete scored read.
351#[derive(Debug, Clone)]
352pub struct AuthorizedReadSnapshot {
353    pub table: String,
354    pub table_snapshot: Snapshot,
355    pub data_generation: u64,
356    pub security_version: u64,
357    pub allowed_row_ids: Option<HashSet<RowId>>,
358}
359
360type RlsCacheKey = (String, u64, u64, String);
361
362/// Runtime statistics for the byte-bounded RLS candidate cache.
363#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
364pub struct RlsCacheStats {
365    pub entries: usize,
366    pub bytes: usize,
367    pub hits: u64,
368    pub misses: u64,
369    pub evictions: u64,
370    pub build_nanos: u64,
371    pub rows_evaluated: u64,
372}
373
374const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
375
376#[derive(Default)]
377struct RlsCache {
378    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
379    lru: VecDeque<RlsCacheKey>,
380    bytes: usize,
381    hits: u64,
382    misses: u64,
383    evictions: u64,
384    build_nanos: u64,
385    rows_evaluated: u64,
386}
387
388impl RlsCache {
389    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
390        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
391        if value.is_some() {
392            self.hits = self.hits.saturating_add(1);
393            self.touch(key);
394        } else {
395            self.misses = self.misses.saturating_add(1);
396        }
397        value
398    }
399
400    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
401        let bytes = key
402            .0
403            .len()
404            .saturating_add(key.3.len())
405            .saturating_add(
406                value
407                    .capacity()
408                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
409            )
410            .saturating_add(std::mem::size_of::<RlsCacheKey>());
411        if bytes > RLS_CACHE_MAX_BYTES {
412            return;
413        }
414        if let Some((_, old_bytes)) = self.entries.remove(&key) {
415            self.bytes = self.bytes.saturating_sub(old_bytes);
416        }
417        self.lru.retain(|candidate| candidate != &key);
418        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
419            let Some(oldest) = self.lru.pop_front() else {
420                break;
421            };
422            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
423                self.bytes = self.bytes.saturating_sub(old_bytes);
424                self.evictions = self.evictions.saturating_add(1);
425            }
426        }
427        self.bytes = self.bytes.saturating_add(bytes);
428        self.lru.push_back(key.clone());
429        self.entries.insert(key, (value, bytes));
430    }
431
432    fn touch(&mut self, key: &RlsCacheKey) {
433        self.lru.retain(|candidate| candidate != key);
434        self.lru.push_back(key.clone());
435    }
436
437    fn stats(&self) -> RlsCacheStats {
438        RlsCacheStats {
439            entries: self.entries.len(),
440            bytes: self.bytes,
441            hits: self.hits,
442            misses: self.misses,
443            evictions: self.evictions,
444            build_nanos: self.build_nanos,
445            rows_evaluated: self.rows_evaluated,
446        }
447    }
448}
449
450/// A handle to a live table inside a [`Database`]. Writes take the inner lock
451/// (P1); P3.3 replaces this with lock-free `ArcSwap` reads + a publish lock for
452/// writes.
453pub type TableHandle = Arc<Mutex<Table>>;
454
455/// Knobs for [`Database::open_with_options`].
456///
457/// All fields default to the same values the convenience
458/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
459/// so `OpenOptions::default()` round-trips the historical behavior exactly.
460#[derive(Clone, Debug, Default)]
461pub struct OpenOptions {
462    /// Maximum time, in milliseconds, to wait for the cross-process database
463    /// lock (`_meta/.lock`) before failing the open with `MongrelError::Io`.
464    ///
465    /// `0` (the default) preserves the historical fail-fast semantics: a
466    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
467    /// `busy_timeout` semantics kick in once this is non-zero — the open
468    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
469    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
470    /// point the open returns the same `Io(WouldBlock)` error the fail-fast
471    /// path would.
472    ///
473    /// Only the cross-process lock is affected. Mounted tables, page-cache
474    /// misses, and WAL appends already serialize through in-process locks
475    /// that handle their own contention.
476    pub lock_timeout_ms: u32,
477}
478
479impl OpenOptions {
480    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
481    /// SQLite-style applications typically pick 1_000 – 5_000ms.
482    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
483        self.lock_timeout_ms = ms;
484        self
485    }
486}
487
488/// A multi-table database: one catalog, one epoch clock, shared caches, a
489/// shared WAL, and a live map of name → `Arc<Table>`.
490pub struct Database {
491    root: PathBuf,
492    /// Set by `_meta/replica`; user writes are rejected on follower copies.
493    read_only: bool,
494    catalog: RwLock<Catalog>,
495    rls_cache: Mutex<RlsCache>,
496    epoch: Arc<EpochAuthority>,
497    snapshots: Arc<SnapshotRegistry>,
498    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
499    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
500    commit_lock: Arc<Mutex<()>>,
501    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
502    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
503    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
504    /// writes also land in this one WAL (B1 — one WAL per database).
505    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
506    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
507    /// in P2.7; here it just needs to be unique within an open. Shared with
508    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
509    next_txn_id: Arc<Mutex<u64>>,
510    tables: RwLock<HashMap<u64, TableHandle>>,
511    kek: Option<Arc<crate::encryption::Kek>>,
512    /// Serializes DDL (create/drop table); data commits serialize through
513    /// `commit_lock` shared via `SharedCtx`.
514    ddl_lock: Mutex<()>,
515    meta_dek: Option<[u8; META_DEK_LEN]>,
516    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
517    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
518    spill_threshold: std::sync::atomic::AtomicU64,
519    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
520    /// detection (spec §9.2).
521    conflicts: crate::txn::ConflictIndex,
522    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
523    /// pruning (spec §9.2, review fix #12).
524    active_txns: crate::txn::ActiveTxns,
525    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
526    /// Shared with mounted tables so a single-table commit also honors poison.
527    poisoned: Arc<std::sync::atomic::AtomicBool>,
528    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
529    /// but defers the fsync to one leader here, so concurrent commits share a
530    /// single fsync (spec §9.3). Shared with mounted tables.
531    group: Arc<crate::txn::GroupCommit>,
532    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
533    /// live spill's pending dir (review fix #14, spec §6.4).
534    active_spills: Arc<crate::retention::ActiveSpills>,
535    /// A write lock captures a consistent bootstrap image; transaction commits
536    /// hold a read lock across spill preparation, WAL append, and publish.
537    replication_barrier: parking_lot::RwLock<()>,
538    /// Number of rotated WAL segments retained for lagging followers.
539    replication_wal_retention_segments: AtomicUsize,
540    /// Live immutable run files being copied by online backups. Compaction may
541    /// retire these runs, but GC cannot unlink them until the backup guard
542    /// drops after the atomic install.
543    backup_pins: Mutex<HashMap<(u64, u128), usize>>,
544    /// Test-only barrier invoked after a transaction writes its spill runs but
545    /// before the sequencer/publish, so tests can race `gc()` against an
546    /// in-flight spill. `None` in production.
547    #[doc(hidden)]
548    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
549    /// Test seam after a backup boundary is captured and before pinned runs are
550    /// copied. Lets tests compact+GC the source at the worst possible moment.
551    #[doc(hidden)]
552    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
553    trigger_recursive: AtomicBool,
554    trigger_max_depth: AtomicU32,
555    trigger_max_loop_iterations: AtomicU32,
556    /// Exclusive cross-process lock held for the database's lifetime to prevent
557    /// two processes from opening the same directory concurrently.
558    _lock: Option<std::fs::File>,
559    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
560    /// is reconstructed from the WAL by [`Database::change_events_since`].
561    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
562    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
563    /// from the WAL, so lagged receivers lose only a wake-up, never data.
564    change_wake: tokio::sync::broadcast::Sender<()>,
565    /// The authenticated principal for this handle. `None` on databases
566    /// opened without credentials (the default — `require_auth = false`),
567    /// `Some` on credentialed opens. Consulted by every enforcement point
568    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
569    /// because the access pattern is read-heavy: every `require()` call
570    /// reads the principal, while writes happen only at open, `enable_auth`,
571    /// and `refresh_principal`. This matches the engine's existing use of
572    /// `RwLock` for `catalog` and `tables`.
573    /// See `docs/15-credential-enforcement.md`.
574    principal: RwLock<Option<crate::auth::Principal>>,
575    /// Shared, cloneable handle to the auth state (require_auth flag from the
576    /// catalog + the principal). Cloned into every mounted `Table` so the
577    /// Table layer can enforce permissions without holding a reference back
578    /// to `Database` (which would create a cycle). `AuthState` is already
579    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
580    auth_state: crate::auth_state::AuthState,
581}
582
583/// RAII guard that ensures an assigned epoch is resolved (published or
584/// abandoned) on every code path, including early `?` returns.
585///
586/// On drop, if not disarmed, calls [`EpochAuthority::abandon`] — the operation
587/// failed, so the epoch must not become visible to readers. On success, the
588/// caller calls [`EpochAuthority::publish_in_order`] and then
589/// [`Self::disarm`] to prevent the abandon.
590///
591/// This is the root-cause fix for the epoch-hole bug: previously, if a DDL or
592/// auth operation failed after `bump_assigned` but before `advance_visible`,
593/// the epoch was never published, permanently blocking the in-order watermark
594/// and making all subsequent queries return empty results.
595struct EpochGuard<'a> {
596    authority: &'a EpochAuthority,
597    epoch: Epoch,
598    armed: bool,
599}
600
601struct BackupRunPins<'a> {
602    pins: &'a Mutex<HashMap<(u64, u128), usize>>,
603    runs: Vec<(u64, u128)>,
604}
605
606struct BackupFilePins {
607    root: PathBuf,
608}
609
610impl Drop for BackupFilePins {
611    fn drop(&mut self) {
612        let _ = std::fs::remove_dir_all(&self.root);
613    }
614}
615
616impl Drop for BackupRunPins<'_> {
617    fn drop(&mut self) {
618        let mut pins = self.pins.lock();
619        for run in &self.runs {
620            if let Some(count) = pins.get_mut(run) {
621                *count -= 1;
622                if *count == 0 {
623                    pins.remove(run);
624                }
625            }
626        }
627    }
628}
629
630impl<'a> EpochGuard<'a> {
631    fn new(authority: &'a EpochAuthority, epoch: Epoch) -> Self {
632        Self {
633            authority,
634            epoch,
635            armed: true,
636        }
637    }
638
639    /// Mark the epoch as successfully published. Call this after
640    /// `publish_in_order` to prevent the guard from abandoning the epoch.
641    fn disarm(&mut self) {
642        self.armed = false;
643    }
644}
645
646impl Drop for EpochGuard<'_> {
647    fn drop(&mut self) {
648        if self.armed {
649            self.authority.abandon(self.epoch);
650        }
651    }
652}
653
654/// A durable data-change event reconstructed from committed WAL records, or an
655/// ephemeral SQL `NOTIFY` event when `id` is `None`.
656#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
657pub struct ChangeEvent {
658    pub id: Option<String>,
659    pub channel: String,
660    pub table_id: Option<u64>,
661    pub table: String,
662    pub op: String,
663    pub epoch: u64,
664    pub txn_id: Option<u64>,
665    pub message: Option<String>,
666    pub data: Option<serde_json::Value>,
667}
668
669#[derive(Debug, Clone)]
670pub struct CdcBatch {
671    pub events: Vec<ChangeEvent>,
672    pub current_epoch: u64,
673    pub earliest_epoch: Option<u64>,
674    pub gap: bool,
675}
676
677/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
678/// (root, epoch, table count, encryption/auth state) without requiring every
679/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
680/// The raw field types carry locks, trait objects, and channels that have no
681/// useful `Debug` output, so a hand-written impl is clearer than peppering
682/// `#[allow(dead_code)]` skip attributes across two dozen fields.
683impl std::fmt::Debug for Database {
684    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
685        let cat = self.catalog.read();
686        let principal_guard = self.principal.read();
687        let principal: &str = principal_guard
688            .as_ref()
689            .map(|p| p.username.as_str())
690            .unwrap_or("<none>");
691        f.debug_struct("Database")
692            .field("root", &self.root)
693            .field("db_epoch", &cat.db_epoch)
694            .field("open_generation", &"sidecar")
695            .field("tables", &cat.tables.len())
696            .field("visible_epoch", &self.epoch.visible().0)
697            .field("encrypted", &self.kek.is_some())
698            .field("require_auth", &cat.require_auth)
699            .field("principal", &principal)
700            .finish()
701    }
702}
703
704impl Database {
705    /// Create a fresh plaintext database at `root`.
706    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
707        Self::create_inner(root, None)
708    }
709
710    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
711    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
712    #[cfg(feature = "encryption")]
713    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
714        let root = root.as_ref();
715        Self::reject_existing_database(root)?;
716        std::fs::create_dir_all(root)?;
717        std::fs::create_dir_all(root.join(META_DIR))?;
718        let salt = crate::encryption::random_salt();
719        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
720        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
721        Self::create_inner(root, Some(kek))
722    }
723
724    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
725    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
726    #[cfg(feature = "encryption")]
727    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
728        let root = root.as_ref();
729        Self::reject_existing_database(root)?;
730        std::fs::create_dir_all(root)?;
731        std::fs::create_dir_all(root.join(META_DIR))?;
732        let salt = crate::encryption::random_salt();
733        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
734        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
735        Self::create_inner(root, Some(kek))
736    }
737
738    fn create_inner(
739        root: impl AsRef<Path>,
740        kek: Option<Arc<crate::encryption::Kek>>,
741    ) -> Result<Self> {
742        let root = root.as_ref().to_path_buf();
743        Self::reject_existing_database(&root)?;
744        std::fs::create_dir_all(&root)?;
745        std::fs::create_dir_all(root.join(TABLES_DIR))?;
746        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
747        let cat = Catalog::empty();
748        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
749        Self::finish_open(root, cat, kek, meta_dek, false, None, 0)
750    }
751
752    /// Open an existing plaintext database.
753    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
754        Self::open_inner(root, None, None)
755    }
756
757    /// Open an existing encrypted database with a passphrase.
758    #[cfg(feature = "encryption")]
759    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
760        let root = root.as_ref();
761        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
762            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
763        let mut salt = [0u8; crate::encryption::SALT_LEN];
764        salt.copy_from_slice(&salt_bytes);
765        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
766        Self::open_inner(root, Some(kek), None)
767    }
768
769    /// Open an existing encrypted database with a configurable cross-process
770    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
771    #[cfg(feature = "encryption")]
772    pub fn open_encrypted_with_options(
773        root: impl AsRef<Path>,
774        passphrase: &str,
775        options: OpenOptions,
776    ) -> Result<Self> {
777        let root = root.as_ref();
778        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
779            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
780        let mut salt = [0u8; crate::encryption::SALT_LEN];
781        salt.copy_from_slice(&salt_bytes);
782        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
783        Self::open_inner_with_lock_timeout(root, Some(kek), None, options.lock_timeout_ms)
784    }
785
786    /// Open an existing encrypted database using a raw high-entropy key.
787    #[cfg(feature = "encryption")]
788    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
789        let root = root.as_ref();
790        let salt_path = root.join(META_DIR).join(KEYS_FILENAME);
791        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
792            MongrelError::NotFound(format!(
793                "encryption salt file {:?}: {e} (database not encrypted, or corrupted)",
794                salt_path
795            ))
796        })?;
797        if salt_bytes.len() != crate::encryption::SALT_LEN {
798            return Err(MongrelError::InvalidArgument(format!(
799                "salt file is {} bytes, expected {}",
800                salt_bytes.len(),
801                crate::encryption::SALT_LEN
802            )));
803        }
804        let mut salt = [0u8; crate::encryption::SALT_LEN];
805        salt.copy_from_slice(&salt_bytes);
806        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
807        Self::open_inner(root, Some(kek), None)
808    }
809
810    /// Open an existing plaintext database that has `require_auth = true`,
811    /// verifying the supplied credentials up front and caching the resolved
812    /// [`Principal`] on the returned handle. Every subsequent operation will
813    /// be checked against that principal.
814    ///
815    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
816    /// `require_auth` enabled — callers must pick the matching constructor for
817    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
818    /// bad username/password.
819    ///
820    /// See `docs/15-credential-enforcement.md`.
821    pub fn open_with_credentials(
822        root: impl AsRef<Path>,
823        username: &str,
824        password: &str,
825    ) -> Result<Self> {
826        Self::open_inner_with_credentials(root, None, username, password)
827    }
828
829    /// Open with credentials and a configurable cross-process lock timeout.
830    /// Mirrors [`open_with_options`](Self::open_with_options) for the
831    /// credentialed path.
832    pub fn open_with_credentials_and_options(
833        root: impl AsRef<Path>,
834        username: &str,
835        password: &str,
836        options: OpenOptions,
837    ) -> Result<Self> {
838        Self::open_inner_with_credentials_and_lock_timeout(
839            root,
840            None,
841            username,
842            password,
843            options.lock_timeout_ms,
844        )
845    }
846
847    /// Open an existing encrypted database that has `require_auth = true`,
848    /// combining the encryption passphrase flow with credential verification.
849    #[cfg(feature = "encryption")]
850    pub fn open_encrypted_with_credentials(
851        root: impl AsRef<Path>,
852        passphrase: &str,
853        username: &str,
854        password: &str,
855    ) -> Result<Self> {
856        let root = root.as_ref();
857        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
858            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
859        let mut salt = [0u8; crate::encryption::SALT_LEN];
860        salt.copy_from_slice(&salt_bytes);
861        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
862        Self::open_inner_with_credentials(root, Some(kek), username, password)
863    }
864
865    /// Open an encrypted + credentialed database with a configurable
866    /// cross-process lock timeout. Mirrors
867    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
868    #[cfg(feature = "encryption")]
869    pub fn open_encrypted_with_credentials_and_options(
870        root: impl AsRef<Path>,
871        passphrase: &str,
872        username: &str,
873        password: &str,
874        options: OpenOptions,
875    ) -> Result<Self> {
876        let root = root.as_ref();
877        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
878            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
879        let mut salt = [0u8; crate::encryption::SALT_LEN];
880        salt.copy_from_slice(&salt_bytes);
881        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
882        Self::open_inner_with_credentials_and_lock_timeout(
883            root,
884            Some(kek),
885            username,
886            password,
887            options.lock_timeout_ms,
888        )
889    }
890
891    /// Open an existing database with non-default [`OpenOptions`].
892    ///
893    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
894    /// rather than the fail-fast default. The other open constructors keep
895    /// their previous defaults; use their `*_with_options` variants when they
896    /// need the same timeout behavior.
897    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
898        // No encryption, no auth; encrypted and credentialed paths have their
899        // own `*_with_options` constructors.
900        Self::open_inner_with_lock_timeout(root, None, None, options.lock_timeout_ms)
901    }
902
903    fn open_inner_with_lock_timeout(
904        root: impl AsRef<Path>,
905        kek: Option<Arc<crate::encryption::Kek>>,
906        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
907        lock_timeout_ms: u32,
908    ) -> Result<Self> {
909        let root = root.as_ref().to_path_buf();
910        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
911        let cat = catalog::read(&root, meta_dek.as_ref())?
912            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
913        Self::finish_open(root, cat, kek, meta_dek, true, None, lock_timeout_ms)
914    }
915
916    /// Shared credentialed-open inner: read the catalog, verify the database
917    /// requires auth, verify the password, resolve the principal, and pass
918    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
919    /// problem where `finish_open`'s fail-closed check (`require_auth &&
920    /// principal.is_none()`) would fire before a post-open `authenticate()`
921    /// could supply the principal.
922    fn open_inner_with_credentials(
923        root: impl AsRef<Path>,
924        kek: Option<Arc<crate::encryption::Kek>>,
925        username: &str,
926        password: &str,
927    ) -> Result<Self> {
928        Self::open_inner_with_credentials_and_lock_timeout(root, kek, username, password, 0)
929    }
930
931    /// Credentialed-open with an explicit cross-process lock timeout. The
932    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
933    /// historical fail-fast behavior via the wrapper above.
934    fn open_inner_with_credentials_and_lock_timeout(
935        root: impl AsRef<Path>,
936        kek: Option<Arc<crate::encryption::Kek>>,
937        username: &str,
938        password: &str,
939        lock_timeout_ms: u32,
940    ) -> Result<Self> {
941        let root = root.as_ref().to_path_buf();
942        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
943        let cat = catalog::read(&root, meta_dek.as_ref())?
944            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
945
946        // Fail early if the database is not in require_auth mode — the caller
947        // picked the wrong constructor.
948        if !cat.require_auth {
949            return Err(MongrelError::AuthNotRequired);
950        }
951
952        // Verify credentials against the on-disk catalog before constructing
953        // the full Database handle. This reads users/hashes directly from the
954        // loaded catalog rather than going through the Database::verify_user
955        // method (which requires a constructed Database).
956        let user = cat
957            .users
958            .iter()
959            .find(|u| u.username == username)
960            .filter(|u| !u.password_hash.is_empty())
961            .ok_or_else(|| MongrelError::InvalidCredentials {
962                username: username.to_string(),
963            })?;
964        let password_ok = crate::auth::verify_password(password, &user.password_hash)
965            .map_err(MongrelError::Other)?;
966        if !password_ok {
967            return Err(MongrelError::InvalidCredentials {
968                username: username.to_string(),
969            });
970        }
971
972        // Resolve the principal from the catalog (roles + permissions).
973        let principal =
974            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
975                MongrelError::InvalidCredentials {
976                    username: username.to_string(),
977                }
978            })?;
979
980        Self::finish_open(
981            root,
982            cat,
983            kek,
984            meta_dek,
985            true,
986            Some(principal),
987            lock_timeout_ms,
988        )
989    }
990
991    /// Create a fresh plaintext database with `require_auth = true` and a
992    /// single admin user. The returned handle is already authenticated as
993    /// that admin — every subsequent operation is checked against the admin
994    /// principal (which bypasses all permission checks via `is_admin`).
995    ///
996    /// This is the bootstrap path: there is no window where the database
997    /// requires auth but has no users.
998    ///
999    /// See `docs/15-credential-enforcement.md`.
1000    pub fn create_with_credentials(
1001        root: impl AsRef<Path>,
1002        admin_username: &str,
1003        admin_password: &str,
1004    ) -> Result<Self> {
1005        Self::create_inner_with_credentials(root, None, admin_username, admin_password)
1006    }
1007
1008    /// Create a fresh encrypted database with `require_auth = true` and a
1009    /// single admin user. Composes encryption-at-rest with credential
1010    /// enforcement.
1011    #[cfg(feature = "encryption")]
1012    pub fn create_encrypted_with_credentials(
1013        root: impl AsRef<Path>,
1014        passphrase: &str,
1015        admin_username: &str,
1016        admin_password: &str,
1017    ) -> Result<Self> {
1018        let root = root.as_ref();
1019        Self::reject_existing_database(root)?;
1020        std::fs::create_dir_all(root)?;
1021        std::fs::create_dir_all(root.join(META_DIR))?;
1022        let salt = crate::encryption::random_salt();
1023        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
1024        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1025        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password)
1026    }
1027
1028    fn create_inner_with_credentials(
1029        root: impl AsRef<Path>,
1030        kek: Option<Arc<crate::encryption::Kek>>,
1031        admin_username: &str,
1032        admin_password: &str,
1033    ) -> Result<Self> {
1034        let root = root.as_ref().to_path_buf();
1035        Self::reject_existing_database(&root)?;
1036        std::fs::create_dir_all(&root)?;
1037        std::fs::create_dir_all(root.join(TABLES_DIR))?;
1038        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1039
1040        // Build the initial catalog with require_auth = true and one admin user.
1041        let password_hash =
1042            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
1043        let mut cat = Catalog::empty();
1044        cat.require_auth = true;
1045        cat.next_user_id = 1;
1046        cat.users.push(crate::auth::UserEntry {
1047            id: 1,
1048            username: admin_username.to_string(),
1049            password_hash,
1050            roles: Vec::new(),
1051            is_admin: true,
1052            created_epoch: 0,
1053        });
1054        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
1055
1056        // The handle is constructed already authenticated as the admin user
1057        // it just created — no separate verify step needed.
1058        let admin_principal = crate::auth::Principal {
1059            username: admin_username.to_string(),
1060            is_admin: true,
1061            roles: Vec::new(),
1062            permissions: Vec::new(),
1063        };
1064        Self::finish_open(root, cat, kek, meta_dek, false, Some(admin_principal), 0)
1065    }
1066
1067    fn reject_existing_database(root: &Path) -> Result<()> {
1068        // Refuse to overwrite an existing database. If CATALOG exists, the
1069        // directory already contains a real database; replacing it destroys data.
1070        if root.join(catalog::CATALOG_FILENAME).exists() {
1071            return Err(MongrelError::InvalidArgument(format!(
1072                "database already exists at {}; use Database::open() to open it, \
1073                 or remove the directory first",
1074                root.display()
1075            )));
1076        }
1077        Ok(())
1078    }
1079
1080    fn open_inner(
1081        root: impl AsRef<Path>,
1082        kek: Option<Arc<crate::encryption::Kek>>,
1083        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
1084    ) -> Result<Self> {
1085        let root = root.as_ref().to_path_buf();
1086        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1087        let cat = catalog::read(&root, meta_dek.as_ref())?
1088            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1089        Self::finish_open(root, cat, kek, meta_dek, true, None, 0)
1090    }
1091
1092    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
1093    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
1094    ///
1095    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
1096    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
1097    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
1098    ///
1099    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
1100    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
1101    /// caller never blocks past its budget even at the tail of a busy lock
1102    /// holder's lock-window.
1103    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
1104        use fs2::FileExt;
1105        if timeout_ms == 0 {
1106            return f.try_lock_exclusive();
1107        }
1108        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
1109        let deadline =
1110            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
1111        let mut next_sleep = std::time::Duration::from_millis(1);
1112        loop {
1113            match f.try_lock_exclusive() {
1114                Ok(()) => return Ok(()),
1115                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1116                    let now = std::time::Instant::now();
1117                    if now >= deadline {
1118                        return Err(std::io::Error::new(
1119                            std::io::ErrorKind::WouldBlock,
1120                            format!("could not acquire database lock within {timeout_ms}ms"),
1121                        ));
1122                    }
1123                    let remaining = deadline - now;
1124                    let sleep = next_sleep.min(remaining);
1125                    std::thread::sleep(sleep);
1126                    // Cap the per-iteration sleep so a single back-off step
1127                    // never overshoots the remaining budget.
1128                    next_sleep = next_sleep
1129                        .saturating_mul(10)
1130                        .min(std::time::Duration::from_millis(50));
1131                }
1132                Err(e) => return Err(e),
1133            }
1134        }
1135    }
1136
1137    fn finish_open(
1138        root: PathBuf,
1139        cat: Catalog,
1140        kek: Option<Arc<crate::encryption::Kek>>,
1141        meta_dek: Option<[u8; META_DEK_LEN]>,
1142        existing: bool,
1143        principal: Option<crate::auth::Principal>,
1144        lock_timeout_ms: u32,
1145    ) -> Result<Self> {
1146        let read_only = existing && root.join(META_DIR).join("replica").exists();
1147        // Acquire an exclusive cross-process lock on the database directory.
1148        // This prevents two *processes* from opening the same DB simultaneously
1149        // (which would corrupt data). Multiple opens within the *same* process
1150        // are allowed (they share memory via Arc) — so we track locked paths in
1151        // a process-global set and skip re-locking if already held.
1152        std::fs::create_dir_all(root.join("_meta")).ok();
1153        let lock_path = root.join("_meta").join(".lock");
1154        let canonical = lock_path.canonicalize().unwrap_or(lock_path.clone());
1155        let lock_file = {
1156            static LOCKED_PATHS: std::sync::OnceLock<
1157                std::sync::Mutex<std::collections::HashSet<PathBuf>>,
1158            > = std::sync::OnceLock::new();
1159            let locked = LOCKED_PATHS
1160                .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
1161            let mut guard = locked.lock().unwrap();
1162            if guard.contains(&canonical) {
1163                // Already locked by this process — allow the re-open.
1164                None
1165            } else {
1166                let f = std::fs::OpenOptions::new()
1167                    .create(true)
1168                    .truncate(false)
1169                    .write(true)
1170                    .open(&lock_path)?;
1171                Self::fs_lock_exclusive(&f, lock_timeout_ms).map_err(|e| {
1172                    MongrelError::Io(std::io::Error::other(format!(
1173                        "database at {} is locked by another process: {e}",
1174                        root.display()
1175                    )))
1176                })?;
1177                guard.insert(canonical.clone());
1178                Some(f)
1179            }
1180        };
1181        if lock_file.is_some() {
1182            let stale_backup_pins = root.join(META_DIR).join("backup-pins");
1183            if stale_backup_pins.exists() {
1184                std::fs::remove_dir_all(stale_backup_pins)?;
1185            }
1186        }
1187
1188        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
1189        let snapshots = Arc::new(SnapshotRegistry::new());
1190        let (history_epochs, history_start) = read_history_retention(&root, Epoch(cat.db_epoch))?;
1191        snapshots.configure_history(history_epochs, history_start);
1192        let page_cache = Arc::new(crate::cache::Sharded::new(
1193            crate::cache::CACHE_SHARDS,
1194            || {
1195                crate::cache::PageCache::new(
1196                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
1197                )
1198            },
1199        ));
1200        let decoded_cache = Arc::new(crate::cache::Sharded::new(
1201            crate::cache::CACHE_SHARDS,
1202            || {
1203                crate::cache::DecodedPageCache::new(
1204                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
1205                )
1206            },
1207        ));
1208        let commit_lock = Arc::new(Mutex::new(()));
1209        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1210        let shared_wal = Arc::new(Mutex::new(if existing {
1211            crate::wal::SharedWal::open(&root, Epoch(cat.db_epoch), wal_dek.clone())?
1212        } else {
1213            crate::wal::SharedWal::create_with_dek(&root, Epoch(cat.db_epoch), wal_dek.clone())?
1214        }));
1215        // Shared write-path state handed to every mounted table so single-table
1216        // `put`/`commit` writes route through the one shared WAL, the one group-
1217        // commit coordinator, and the one poison flag (B1).
1218        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
1219        let group = Arc::new(crate::txn::GroupCommit::new(
1220            shared_wal.lock().durable_seq(),
1221        ));
1222        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
1223        // Final base value is set after the open-generation bump below; tables
1224        // only draw ids once the user issues a write (post-open), so the
1225        // placeholder is never observed.
1226        let txn_ids = Arc::new(Mutex::new(1u64));
1227
1228        // Recover DDL from the shared WAL BEFORE opening tables (spec §15,
1229        // review fix #16). A crash between WAL fsync and the catalog
1230        // checkpoint leaves committed DDL durable in the WAL but absent from
1231        // the on-disk catalog; replay it here so the table-mounting loop and
1232        // data-record recovery see a correct catalog.
1233        let mut cat = cat;
1234        if existing {
1235            recover_ddl_from_wal(&root, &mut cat, meta_dek.as_ref(), wal_dek.as_ref())?;
1236        }
1237
1238        // Build the shared auth state early — it's cloned into every mounted
1239        // Table's SharedCtx so the Table layer can enforce permissions without
1240        // a reference back to Database. The `require_auth` flag is mirrored
1241        // from the catalog; `enable_auth` / `refresh_principal` update it live.
1242        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
1243        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
1244            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
1245        ));
1246
1247        // Open every live table against the shared context. Mounted tables have
1248        // no private WAL (B1) — `open_in` just loads the manifest/runs and
1249        // advances the shared epoch authority to its manifest epoch, so the
1250        // final shared watermark is the max across all tables. All of a mounted
1251        // table's committed records are replayed below from the shared WAL.
1252        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
1253        for entry in &cat.tables {
1254            if !matches!(entry.state, TableState::Live) {
1255                continue;
1256            }
1257            let tdir = root.join(TABLES_DIR).join(entry.table_id.to_string());
1258            let ctx = SharedCtx {
1259                epoch: Arc::clone(&epoch),
1260                page_cache: Arc::clone(&page_cache),
1261                decoded_cache: Arc::clone(&decoded_cache),
1262                snapshots: Arc::clone(&snapshots),
1263                kek: kek.clone(),
1264                commit_lock: Arc::clone(&commit_lock),
1265                shared: Some(crate::engine::SharedWalCtx {
1266                    wal: Arc::clone(&shared_wal),
1267                    group: Arc::clone(&group),
1268                    poisoned: Arc::clone(&poisoned),
1269                    txn_ids: Arc::clone(&txn_ids),
1270                    change_wake: change_wake.clone(),
1271                }),
1272                table_name: Some(entry.name.clone()),
1273                auth: auth_checker.clone(),
1274                read_only,
1275            };
1276            let t = Table::open_in(&tdir, ctx)?;
1277            tables.insert(entry.table_id, Arc::new(Mutex::new(t)));
1278        }
1279
1280        // Recover transaction writes from the shared WAL (spec §15). This is the
1281        // single durability source for mounted tables: it applies every committed
1282        // record — both single-table `Table::commit` writes and cross-table
1283        // transactions — gated by each table's `flushed_epoch` (records already
1284        // durable in a run are not re-applied).
1285        if existing {
1286            recover_shared_wal(&root, &tables, &epoch, wal_dek.as_ref())?;
1287            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
1288            // large transactions (spec §8.5, review fix #14).
1289            sweep_pending_txn_dirs(&root, &cat);
1290        }
1291
1292        // Bump `open_generation` on every open and scope transaction ids by it
1293        // (`txn_id = (generation << 32) | counter`), so ids never alias across
1294        // reopens (review fix #11). Persist the bumped generation to a sidecar
1295        // file (`_meta/generation`) rather than CATALOG, so CATALOG stays
1296        // byte-stable across bare opens for content-addressed storage.
1297        let open_generation = if existing {
1298            let meta_dir = root.join(META_DIR);
1299            let gen = catalog::read_generation(&meta_dir);
1300            let bumped = gen.wrapping_add(1);
1301            catalog::write_generation(&meta_dir, bumped)?;
1302            bumped
1303        } else {
1304            0
1305        };
1306        let next_txn_id = (open_generation << 32) | 1;
1307        // Seed the shared txn-id allocator now that the generation is final.
1308        *txn_ids.lock() = next_txn_id;
1309
1310        // Fail-closed: an existing database with `require_auth = true` must be
1311        // opened with credentials (a non-None principal). The credentialed
1312        // constructors pass the principal through finish_open; the plain
1313        // open/open_encrypted paths pass None and are rejected here. A brand-
1314        // new database (`existing = false`) never has require_auth set yet
1315        // (create_with_credentials sets it in the catalog before construction
1316        // AND passes the principal), so the check only gates the reopen path.
1317        if existing && cat.require_auth && principal.is_none() {
1318            return Err(MongrelError::AuthRequired);
1319        }
1320
1321        Ok(Self {
1322            root,
1323            read_only,
1324            catalog: RwLock::new(cat),
1325            rls_cache: Mutex::new(RlsCache::default()),
1326            epoch,
1327            snapshots,
1328            page_cache,
1329            decoded_cache,
1330            commit_lock,
1331            shared_wal,
1332            next_txn_id: txn_ids,
1333            tables: RwLock::new(tables),
1334            kek,
1335            ddl_lock: Mutex::new(()),
1336            meta_dek,
1337            conflicts: crate::txn::ConflictIndex::new(),
1338            active_txns: crate::txn::ActiveTxns::new(),
1339            poisoned,
1340            group,
1341            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
1342            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
1343            replication_barrier: parking_lot::RwLock::new(()),
1344            replication_wal_retention_segments: AtomicUsize::new(0),
1345            backup_pins: Mutex::new(HashMap::new()),
1346            spill_hook: Mutex::new(None),
1347            backup_hook: Mutex::new(None),
1348            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
1349            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
1350            trigger_max_loop_iterations: AtomicU32::new(
1351                TriggerConfig::default().max_loop_iterations,
1352            ),
1353            _lock: lock_file,
1354            notify: {
1355                let (tx, _rx) = tokio::sync::broadcast::channel(256);
1356                tx
1357            },
1358            change_wake,
1359            principal: RwLock::new(principal),
1360            auth_state,
1361        })
1362    }
1363
1364    /// The current reader-visible epoch.
1365    pub fn visible_epoch(&self) -> Epoch {
1366        self.epoch.visible()
1367    }
1368
1369    /// Clone the in-memory catalog (for diagnostics / tests).
1370    pub fn catalog_snapshot(&self) -> Catalog {
1371        self.catalog.read().clone()
1372    }
1373
1374    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
1375        self.catalog
1376            .read()
1377            .materialized_views
1378            .iter()
1379            .find(|definition| definition.name == name)
1380            .cloned()
1381    }
1382
1383    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
1384        self.catalog.read().materialized_views.clone()
1385    }
1386
1387    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
1388        self.catalog.read().security.clone()
1389    }
1390
1391    pub fn security_active_for(&self, table: &str) -> bool {
1392        self.catalog.read().security.table_has_security(table)
1393    }
1394
1395    /// Persist a complete validated RLS/masking catalog through the WAL.
1396    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
1397        self.set_security_catalog_as(security, None)
1398    }
1399
1400    /// Persist security policy changes on behalf of an explicit request principal.
1401    pub fn set_security_catalog_as(
1402        &self,
1403        security: crate::security::SecurityCatalog,
1404        principal: Option<&crate::auth::Principal>,
1405    ) -> Result<()> {
1406        use crate::wal::DdlOp;
1407        use std::sync::atomic::Ordering;
1408
1409        self.require_for(principal, &crate::auth::Permission::Admin)?;
1410        if self.poisoned.load(Ordering::Relaxed) {
1411            return Err(MongrelError::Other(
1412                "database poisoned by fsync error".into(),
1413            ));
1414        }
1415        let _ddl = self.ddl_lock.lock();
1416        validate_security_catalog(&self.catalog.read(), &security)?;
1417        let payload = DdlOp::encode_security(&security)?;
1418        let _commit = self.commit_lock.lock();
1419        let epoch = self.epoch.bump_assigned();
1420        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
1421        let txn_id = self.alloc_txn_id();
1422        let commit_seq = {
1423            let mut wal = self.shared_wal.lock();
1424            wal.append(
1425                txn_id,
1426                WAL_TABLE_ID,
1427                crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
1428                    security_json: payload,
1429                }),
1430            )?;
1431            wal.append_commit(txn_id, epoch, &[])?
1432        };
1433        self.group
1434            .await_durable(&self.shared_wal, commit_seq)
1435            .inspect_err(|_| {
1436                self.poisoned.store(true, Ordering::Relaxed);
1437            })?;
1438        {
1439            let mut catalog = self.catalog.write();
1440            catalog.security = security;
1441            catalog.security_version = catalog.security_version.wrapping_add(1);
1442            catalog.db_epoch = catalog.db_epoch.max(epoch.0);
1443        }
1444        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1445        self.epoch.publish_in_order(epoch);
1446        epoch_guard.disarm();
1447        Ok(())
1448    }
1449
1450    pub fn require_for(
1451        &self,
1452        principal: Option<&crate::auth::Principal>,
1453        permission: &crate::auth::Permission,
1454    ) -> Result<()> {
1455        let Some(principal) = principal else {
1456            return self.require(permission);
1457        };
1458        if principal.has_permission(permission) {
1459            Ok(())
1460        } else {
1461            Err(MongrelError::PermissionDenied {
1462                required: permission.clone(),
1463                principal: principal.username.clone(),
1464            })
1465        }
1466    }
1467
1468    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
1469        self.principal.read().clone()
1470    }
1471
1472    pub fn require_columns_for(
1473        &self,
1474        table: &str,
1475        operation: crate::auth::ColumnOperation,
1476        column_ids: &[u16],
1477        principal: Option<&crate::auth::Principal>,
1478    ) -> Result<()> {
1479        let schema = self
1480            .catalog
1481            .read()
1482            .live(table)
1483            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
1484            .schema
1485            .clone();
1486        let cached = self.principal.read().clone();
1487        let principal = principal.or(cached.as_ref());
1488        let Some(principal) = principal else {
1489            let permission = match operation {
1490                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
1491                    table: table.to_string(),
1492                },
1493                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
1494                    table: table.to_string(),
1495                },
1496                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
1497                    table: table.to_string(),
1498                },
1499            };
1500            return self.require(&permission);
1501        };
1502        match principal.column_access(table, operation) {
1503            crate::auth::ColumnAccess::All => Ok(()),
1504            crate::auth::ColumnAccess::Columns(allowed) => {
1505                let denied = column_ids.iter().find_map(|column_id| {
1506                    schema
1507                        .columns
1508                        .iter()
1509                        .find(|column| column.id == *column_id)
1510                        .filter(|column| !allowed.contains(&column.name))
1511                });
1512                if denied.is_none() {
1513                    Ok(())
1514                } else {
1515                    Err(MongrelError::PermissionDenied {
1516                        required: match operation {
1517                            crate::auth::ColumnOperation::Select => {
1518                                crate::auth::Permission::SelectColumns {
1519                                    table: table.to_string(),
1520                                    columns: denied
1521                                        .into_iter()
1522                                        .map(|column| column.name.clone())
1523                                        .collect(),
1524                                }
1525                            }
1526                            crate::auth::ColumnOperation::Insert => {
1527                                crate::auth::Permission::InsertColumns {
1528                                    table: table.to_string(),
1529                                    columns: denied
1530                                        .into_iter()
1531                                        .map(|column| column.name.clone())
1532                                        .collect(),
1533                                }
1534                            }
1535                            crate::auth::ColumnOperation::Update => {
1536                                crate::auth::Permission::UpdateColumns {
1537                                    table: table.to_string(),
1538                                    columns: denied
1539                                        .into_iter()
1540                                        .map(|column| column.name.clone())
1541                                        .collect(),
1542                                }
1543                            }
1544                        },
1545                        principal: principal.username.clone(),
1546                    })
1547                }
1548            }
1549            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
1550                required: match operation {
1551                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
1552                        table: table.to_string(),
1553                    },
1554                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
1555                        table: table.to_string(),
1556                    },
1557                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
1558                        table: table.to_string(),
1559                    },
1560                },
1561                principal: principal.username.clone(),
1562            }),
1563        }
1564    }
1565
1566    pub fn select_column_ids_for(
1567        &self,
1568        table: &str,
1569        principal: Option<&crate::auth::Principal>,
1570    ) -> Result<Vec<u16>> {
1571        let catalog = self.catalog.read();
1572        let columns = catalog
1573            .live(table)
1574            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
1575            .schema
1576            .columns
1577            .iter()
1578            .map(|column| (column.id, column.name.clone()))
1579            .collect::<Vec<_>>();
1580        drop(catalog);
1581        let cached_principal = self.principal.read().clone();
1582        let principal = principal.or(cached_principal.as_ref());
1583        let Some(principal) = principal else {
1584            self.require(&crate::auth::Permission::Select {
1585                table: table.to_string(),
1586            })?;
1587            return Ok(columns.iter().map(|(id, _)| *id).collect());
1588        };
1589        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
1590            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
1591            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
1592                .iter()
1593                .filter(|(_, name)| allowed.contains(name))
1594                .map(|(id, _)| *id)
1595                .collect()),
1596            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
1597                required: crate::auth::Permission::Select {
1598                    table: table.to_string(),
1599                },
1600                principal: principal.username.clone(),
1601            }),
1602        }
1603    }
1604
1605    pub fn secure_rows_for(
1606        &self,
1607        table: &str,
1608        rows: Vec<crate::memtable::Row>,
1609        principal: Option<&crate::auth::Principal>,
1610    ) -> Result<Vec<crate::memtable::Row>> {
1611        let security = self.catalog.read().security.clone();
1612        if !security.table_has_security(table) {
1613            return Ok(rows);
1614        }
1615        let owned;
1616        let principal = match principal {
1617            Some(principal) => principal,
1618            None => {
1619                owned = self
1620                    .principal
1621                    .read()
1622                    .clone()
1623                    .ok_or(MongrelError::AuthRequired)?;
1624                &owned
1625            }
1626        };
1627        let mut output = Vec::new();
1628        for mut row in rows {
1629            if security.row_allowed(
1630                table,
1631                crate::security::PolicyCommand::Select,
1632                &row,
1633                principal,
1634                false,
1635            ) {
1636                security.apply_masks(table, &mut row, principal);
1637                output.push(row);
1638            }
1639        }
1640        Ok(output)
1641    }
1642
1643    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
1644    pub fn authorized_candidate_ids_for(
1645        &self,
1646        table: &str,
1647        principal: Option<&crate::auth::Principal>,
1648    ) -> Result<Option<std::collections::HashSet<RowId>>> {
1649        Ok(self
1650            .authorized_read_snapshot(table, principal)?
1651            .allowed_row_ids)
1652    }
1653
1654    fn allowed_row_ids_locked(
1655        &self,
1656        table_name: &str,
1657        table: &Table,
1658        table_snapshot: Snapshot,
1659        security: &crate::security::SecurityCatalog,
1660        security_version: u64,
1661        principal: Option<&crate::auth::Principal>,
1662    ) -> Result<Option<Arc<HashSet<RowId>>>> {
1663        if !security.rls_enabled(table_name) {
1664            return Ok(None);
1665        }
1666        let authorization_started = std::time::Instant::now();
1667        let principal = principal.ok_or(MongrelError::AuthRequired)?;
1668        let mut roles = principal.roles.clone();
1669        roles.sort_unstable();
1670        let principal_key = format!("{}:{}:{roles:?}", principal.username, principal.is_admin);
1671        let cache_key = (
1672            table_name.to_string(),
1673            table.data_generation(),
1674            security_version,
1675            principal_key,
1676        );
1677        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
1678            crate::trace::QueryTrace::record(|trace| {
1679                trace.rls_cache_hit = true;
1680                trace.authorization_nanos = trace
1681                    .authorization_nanos
1682                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
1683            });
1684            return Ok(Some(allowed));
1685        }
1686        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
1687        let started = std::time::Instant::now();
1688        let rows = table.visible_rows(table_snapshot)?;
1689        let rows_evaluated = rows.len() as u64;
1690        let allowed = Arc::new(
1691            rows.into_iter()
1692                .filter(|row| {
1693                    security.row_allowed(
1694                        table_name,
1695                        crate::security::PolicyCommand::Select,
1696                        row,
1697                        principal,
1698                        false,
1699                    )
1700                })
1701                .map(|row| row.row_id)
1702                .collect(),
1703        );
1704        let mut cache = self.rls_cache.lock();
1705        cache.build_nanos = cache
1706            .build_nanos
1707            .saturating_add(started.elapsed().as_nanos() as u64);
1708        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
1709        cache.insert(cache_key, Arc::clone(&allowed));
1710        crate::trace::QueryTrace::record(|trace| {
1711            trace.rls_rows_evaluated = trace
1712                .rls_rows_evaluated
1713                .saturating_add(rows_evaluated as usize);
1714            trace.authorization_nanos = trace
1715                .authorization_nanos
1716                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
1717        });
1718        Ok(Some(allowed))
1719    }
1720
1721    fn principal_for_authorized_read(
1722        &self,
1723        catalog: &Catalog,
1724        principal: Option<&crate::auth::Principal>,
1725        catalog_bound: bool,
1726    ) -> Result<Option<crate::auth::Principal>> {
1727        let principal = principal.cloned().or_else(|| self.principal.read().clone());
1728        let Some(principal) = principal else {
1729            return Ok(None);
1730        };
1731        if catalog_bound
1732            || catalog
1733                .users
1734                .iter()
1735                .any(|user| user.username == principal.username)
1736        {
1737            return Self::resolve_principal_from_catalog(catalog, &principal.username)
1738                .map(Some)
1739                .ok_or(MongrelError::AuthRequired);
1740        }
1741        Ok(Some(principal))
1742    }
1743
1744    /// Run authorization, candidate generation, ranking, and materialization
1745    /// while holding one table generation. Security changes cause a bounded
1746    /// retry before any result is published.
1747    pub fn with_authorized_read<T, F>(
1748        &self,
1749        table_name: &str,
1750        principal: Option<&crate::auth::Principal>,
1751        catalog_bound: bool,
1752        mut read: F,
1753    ) -> Result<T>
1754    where
1755        F: FnMut(
1756            &mut Table,
1757            Snapshot,
1758            Option<&HashSet<RowId>>,
1759            Option<&crate::auth::Principal>,
1760        ) -> Result<T>,
1761    {
1762        const RETRIES: usize = 3;
1763        let handle = self.table(table_name)?;
1764        for attempt in 0..RETRIES {
1765            crate::trace::QueryTrace::record(|trace| {
1766                trace.authorization_retries = attempt;
1767            });
1768            let (security, security_version, effective_principal) = {
1769                let catalog = self.catalog.read();
1770                (
1771                    catalog.security.clone(),
1772                    catalog.security_version,
1773                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
1774                )
1775            };
1776            let result = {
1777                let mut table = handle.lock();
1778                let snapshot = table.snapshot();
1779                let allowed = self.allowed_row_ids_locked(
1780                    table_name,
1781                    &table,
1782                    snapshot,
1783                    &security,
1784                    security_version,
1785                    effective_principal.as_ref(),
1786                )?;
1787                read(
1788                    &mut table,
1789                    snapshot,
1790                    allowed.as_deref(),
1791                    effective_principal.as_ref(),
1792                )?
1793            };
1794            if self.catalog.read().security_version == security_version {
1795                return Ok(result);
1796            }
1797            if attempt + 1 == RETRIES {
1798                return Err(MongrelError::Conflict(
1799                    "security policy changed during scored read".into(),
1800                ));
1801            }
1802        }
1803        unreachable!()
1804    }
1805
1806    /// Execute a native conjunctive read with the database principal's row
1807    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
1808    /// policy-unaware; language bindings must use this boundary for reads.
1809    pub fn query_for_current_principal(
1810        &self,
1811        table_name: &str,
1812        query: &crate::query::Query,
1813        projection: Option<&[u16]>,
1814    ) -> Result<Vec<crate::memtable::Row>> {
1815        let condition_columns = crate::query::condition_columns(&query.conditions);
1816        self.with_authorized_read(
1817            table_name,
1818            None,
1819            true,
1820            |table, snapshot, allowed, principal| {
1821                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
1822                self.require_columns_for(
1823                    table_name,
1824                    crate::auth::ColumnOperation::Select,
1825                    &condition_columns,
1826                    principal,
1827                )?;
1828                if let Some(projection) = projection {
1829                    self.require_columns_for(
1830                        table_name,
1831                        crate::auth::ColumnOperation::Select,
1832                        projection,
1833                        principal,
1834                    )?;
1835                }
1836                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
1837                let projection =
1838                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
1839                for row in &mut rows {
1840                    row.columns.retain(|column, _| {
1841                        allowed_columns.contains(column)
1842                            && projection
1843                                .as_ref()
1844                                .map_or(true, |projection| projection.contains(column))
1845                    });
1846                }
1847                self.secure_rows_for(table_name, rows, principal)
1848            },
1849        )
1850    }
1851
1852    /// Read one row with the database principal's row policy, column grants,
1853    /// and masks applied.
1854    pub fn get_for_current_principal(
1855        &self,
1856        table_name: &str,
1857        row_id: RowId,
1858    ) -> Result<Option<crate::memtable::Row>> {
1859        self.with_authorized_read(
1860            table_name,
1861            None,
1862            true,
1863            |table, snapshot, allowed, principal| {
1864                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
1865                let Some(row) = table.get(row_id, snapshot) else {
1866                    return Ok(None);
1867                };
1868                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
1869                    return Ok(None);
1870                }
1871                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
1872                if let Some(row) = rows.first_mut() {
1873                    row.columns
1874                        .retain(|column, _| allowed_columns.contains(column));
1875                }
1876                Ok(rows.pop())
1877            },
1878        )
1879    }
1880
1881    /// Run exact ANN reranking over only rows authorized for this database
1882    /// handle. The embedding column still requires normal column access.
1883    pub fn ann_rerank_for_current_principal(
1884        &self,
1885        table_name: &str,
1886        request: &crate::query::AnnRerankRequest,
1887    ) -> Result<Vec<crate::query::AnnRerankHit>> {
1888        self.with_authorized_read(
1889            table_name,
1890            None,
1891            true,
1892            |table, snapshot, allowed, principal| {
1893                self.require_columns_for(
1894                    table_name,
1895                    crate::auth::ColumnOperation::Select,
1896                    &[request.column_id],
1897                    principal,
1898                )?;
1899                table.ann_rerank_at(request, snapshot, allowed)
1900            },
1901        )
1902    }
1903
1904    /// Capture one table snapshot and the security version used to authorize it.
1905    /// The caller must validate the returned version before publishing results.
1906    pub fn authorized_read_snapshot(
1907        &self,
1908        table: &str,
1909        principal: Option<&crate::auth::Principal>,
1910    ) -> Result<AuthorizedReadSnapshot> {
1911        let (security, security_version, effective_principal) = {
1912            let catalog = self.catalog.read();
1913            (
1914                catalog.security.clone(),
1915                catalog.security_version,
1916                self.principal_for_authorized_read(&catalog, principal, false)?,
1917            )
1918        };
1919        let handle = self.table(table)?;
1920        let (table_snapshot, data_generation, allowed_row_ids) = {
1921            let table_handle = handle.lock();
1922            let table_snapshot = table_handle.snapshot();
1923            let data_generation = table_handle.data_generation();
1924            let allowed = self.allowed_row_ids_locked(
1925                table,
1926                &table_handle,
1927                table_snapshot,
1928                &security,
1929                security_version,
1930                effective_principal.as_ref(),
1931            )?;
1932            (
1933                table_snapshot,
1934                data_generation,
1935                allowed.map(|allowed| (*allowed).clone()),
1936            )
1937        };
1938        Ok(AuthorizedReadSnapshot {
1939            table: table.to_string(),
1940            table_snapshot,
1941            data_generation,
1942            security_version,
1943            allowed_row_ids,
1944        })
1945    }
1946
1947    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
1948        if self.catalog.read().security_version != snapshot.security_version {
1949            return false;
1950        }
1951        self.table(&snapshot.table)
1952            .ok()
1953            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
1954    }
1955
1956    pub fn rls_cache_stats(&self) -> RlsCacheStats {
1957        self.rls_cache.lock().stats()
1958    }
1959
1960    /// Read visible rows with column authorization, RLS, and masks applied.
1961    pub fn rows_for(
1962        &self,
1963        table: &str,
1964        principal: Option<&crate::auth::Principal>,
1965    ) -> Result<Vec<crate::memtable::Row>> {
1966        let allowed = self.select_column_ids_for(table, principal)?;
1967        let handle = self.table(table)?;
1968        let rows = {
1969            let table = handle.lock();
1970            table.visible_rows(table.snapshot())?
1971        };
1972        let mut rows = self.secure_rows_for(table, rows, principal)?;
1973        for row in &mut rows {
1974            row.columns.retain(|column, _| allowed.contains(column));
1975        }
1976        Ok(rows)
1977    }
1978
1979    /// Count rows visible to a principal without bypassing RLS.
1980    pub fn count_for(
1981        &self,
1982        table: &str,
1983        principal: Option<&crate::auth::Principal>,
1984    ) -> Result<u64> {
1985        self.select_column_ids_for(table, principal)?;
1986        if self.security_active_for(table) {
1987            Ok(self.rows_for(table, principal)?.len() as u64)
1988        } else {
1989            Ok(self.table(table)?.lock().count())
1990        }
1991    }
1992
1993    /// Authorize and write one native-API row for an explicit principal.
1994    pub fn put_for(
1995        &self,
1996        table: &str,
1997        mut cells: Vec<(u16, crate::memtable::Value)>,
1998        principal: Option<&crate::auth::Principal>,
1999    ) -> Result<RowId> {
2000        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
2001        self.require_columns_for(
2002            table,
2003            crate::auth::ColumnOperation::Insert,
2004            &columns,
2005            principal,
2006        )?;
2007        let handle = self.table(table)?;
2008        let mut table_handle = handle.lock();
2009        table_handle.fill_auto_inc(&mut cells)?;
2010        table_handle.apply_defaults(&mut cells)?;
2011        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
2012        row.columns.extend(cells.iter().cloned());
2013        self.check_row_policy_for(
2014            table,
2015            crate::security::PolicyCommand::Insert,
2016            &row,
2017            true,
2018            principal,
2019        )?;
2020        table_handle.put(cells)
2021    }
2022
2023    pub fn check_row_policy_for(
2024        &self,
2025        table: &str,
2026        command: crate::security::PolicyCommand,
2027        row: &crate::memtable::Row,
2028        check_new: bool,
2029        principal: Option<&crate::auth::Principal>,
2030    ) -> Result<()> {
2031        let security = self.catalog.read().security.clone();
2032        if !security.rls_enabled(table) {
2033            return Ok(());
2034        }
2035        let cached = self.principal.read().clone();
2036        let principal = principal
2037            .or(cached.as_ref())
2038            .ok_or(MongrelError::AuthRequired)?;
2039        if security.row_allowed(table, command, row, principal, check_new) {
2040            return Ok(());
2041        }
2042        let required = match command {
2043            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
2044                table: table.to_string(),
2045            },
2046            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
2047                table: table.to_string(),
2048            },
2049            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
2050                table: table.to_string(),
2051            },
2052            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
2053                crate::auth::Permission::Delete {
2054                    table: table.to_string(),
2055                }
2056            }
2057        };
2058        Err(MongrelError::PermissionDenied {
2059            required,
2060            principal: principal.username.clone(),
2061        })
2062    }
2063
2064    /// Durably create or replace a materialized-view definition after its
2065    /// physical table has been populated.
2066    pub fn set_materialized_view(
2067        &self,
2068        definition: crate::catalog::MaterializedViewEntry,
2069    ) -> Result<()> {
2070        use crate::wal::DdlOp;
2071        use std::sync::atomic::Ordering;
2072
2073        self.require(&crate::auth::Permission::Ddl)?;
2074        if self.poisoned.load(Ordering::Relaxed) {
2075            return Err(MongrelError::Other(
2076                "database poisoned by fsync error".into(),
2077            ));
2078        }
2079        if definition.name.is_empty() || definition.query.trim().is_empty() {
2080            return Err(MongrelError::InvalidArgument(
2081                "materialized view name and query must not be empty".into(),
2082            ));
2083        }
2084
2085        let _ddl = self.ddl_lock.lock();
2086        let table_id = self
2087            .catalog
2088            .read()
2089            .live(&definition.name)
2090            .ok_or_else(|| {
2091                MongrelError::NotFound(format!(
2092                    "materialized view table {:?} not found",
2093                    definition.name
2094                ))
2095            })?
2096            .table_id;
2097        let definition_json = DdlOp::encode_materialized_view(&definition)?;
2098        let _commit = self.commit_lock.lock();
2099        let epoch = self.epoch.bump_assigned();
2100        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2101        let txn_id = self.alloc_txn_id();
2102        let commit_seq = {
2103            let mut wal = self.shared_wal.lock();
2104            wal.append(
2105                txn_id,
2106                table_id,
2107                crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
2108                    name: definition.name.clone(),
2109                    definition_json,
2110                }),
2111            )?;
2112            wal.append_commit(txn_id, epoch, &[])?
2113        };
2114        self.group
2115            .await_durable(&self.shared_wal, commit_seq)
2116            .inspect_err(|_| {
2117                self.poisoned.store(true, Ordering::Relaxed);
2118            })?;
2119
2120        {
2121            let mut catalog = self.catalog.write();
2122            if let Some(existing) = catalog
2123                .materialized_views
2124                .iter_mut()
2125                .find(|existing| existing.name == definition.name)
2126            {
2127                *existing = definition;
2128            } else {
2129                catalog.materialized_views.push(definition);
2130            }
2131        }
2132        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2133        self.epoch.publish_in_order(epoch);
2134        epoch_guard.disarm();
2135        Ok(())
2136    }
2137
2138    /// The filesystem root this database was opened/created at.
2139    pub fn root(&self) -> &Path {
2140        &self.root
2141    }
2142
2143    pub fn is_read_only_replica(&self) -> bool {
2144        self.read_only
2145    }
2146
2147    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
2148        self.replication_wal_retention_segments
2149            .store(segments, std::sync::atomic::Ordering::Relaxed);
2150    }
2151
2152    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
2153    /// direct table commits, compaction, and WAL append are quiesced while the
2154    /// file image is read. WAL records newer than manifests remain sufficient
2155    /// for recovery, so no flush or compaction is required.
2156    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
2157        let _barrier = self.replication_barrier.write();
2158        let _ddl = self.ddl_lock.lock();
2159        let mut handles: Vec<_> = self
2160            .tables
2161            .read()
2162            .iter()
2163            .map(|(id, handle)| (*id, Arc::clone(handle)))
2164            .collect();
2165        handles.sort_by_key(|(id, _)| *id);
2166        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
2167        let _commit = self.commit_lock.lock();
2168        let mut wal = self.shared_wal.lock();
2169        wal.group_sync()?;
2170        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
2171        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
2172        let epoch = records
2173            .iter()
2174            .filter_map(|record| match &record.op {
2175                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
2176                _ => None,
2177            })
2178            .max()
2179            .unwrap_or(0)
2180            .max(self.visible_epoch().0);
2181        let files = crate::replication::capture_files(&self.root)?;
2182        drop(wal);
2183        Ok(crate::replication::ReplicationSnapshot::new(epoch, files))
2184    }
2185
2186    /// Create an online, directly-openable backup at `destination`.
2187    ///
2188    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
2189    /// mutable metadata, and pins the exact immutable runs named by the copied
2190    /// manifests. Writers resume while those runs stream into a sibling staging
2191    /// directory. A checksummed backup manifest is written last, then the stage
2192    /// is atomically renamed into place.
2193    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
2194        self.require(&crate::auth::Permission::Ddl)?;
2195        let (destination, parent, stage) =
2196            prepare_backup_destination(&self.root, destination.as_ref())?;
2197        std::fs::create_dir(&stage)?;
2198
2199        let outcome = (|| {
2200            let barrier = self.replication_barrier.write();
2201            let ddl = self.ddl_lock.lock();
2202            let mut handles: Vec<_> = self
2203                .tables
2204                .read()
2205                .iter()
2206                .map(|(id, handle)| (*id, Arc::clone(handle)))
2207                .collect();
2208            handles.sort_by_key(|(id, _)| *id);
2209            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
2210            let commit = self.commit_lock.lock();
2211            let mut wal = self.shared_wal.lock();
2212            wal.group_sync()?;
2213            let epoch = self.visible_epoch().0;
2214
2215            let pin_nonce = std::time::SystemTime::now()
2216                .duration_since(std::time::UNIX_EPOCH)
2217                .unwrap_or_default()
2218                .as_nanos();
2219            let file_pin_root = self
2220                .root
2221                .join(META_DIR)
2222                .join("backup-pins")
2223                .join(format!("{}-{pin_nonce}", std::process::id()));
2224            std::fs::create_dir_all(&file_pin_root)?;
2225            let _file_pins = BackupFilePins {
2226                root: file_pin_root.clone(),
2227            };
2228            let mut run_files = Vec::new();
2229            for (index, (table_id, _)) in handles.iter().enumerate() {
2230                let table = &table_guards[index];
2231                for run in table.run_refs() {
2232                    let source = table.runs_dir().join(format!("r-{}.sr", run.run_id));
2233                    let relative = source
2234                        .strip_prefix(&self.root)
2235                        .map_err(|error| MongrelError::Other(format!("backup run path: {error}")))?
2236                        .to_path_buf();
2237                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
2238                    if std::fs::hard_link(&source, &pinned).is_err() {
2239                        crate::backup::copy_file_synced(&source, &pinned)?;
2240                    }
2241                    run_files.push(((*table_id, run.run_id), pinned, relative));
2242                }
2243            }
2244            std::fs::File::open(&file_pin_root)?.sync_all()?;
2245            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
2246            {
2247                let mut pins = self.backup_pins.lock();
2248                for key in &run_keys {
2249                    *pins.entry(*key).or_insert(0) += 1;
2250                }
2251            }
2252            let _run_pins = BackupRunPins {
2253                pins: &self.backup_pins,
2254                runs: run_keys,
2255            };
2256            let deferred: HashSet<_> = run_files
2257                .iter()
2258                .map(|(_, _, relative)| relative.clone())
2259                .collect();
2260            let mut copied = Vec::new();
2261            copy_backup_boundary(&self.root, &stage, &deferred, &mut copied)?;
2262
2263            drop(wal);
2264            drop(commit);
2265            drop(table_guards);
2266            drop(ddl);
2267            drop(barrier);
2268
2269            if let Some(hook) = self.backup_hook.lock().as_ref() {
2270                hook();
2271            }
2272            for (_, source, relative) in run_files {
2273                crate::backup::copy_file_synced(&source, &stage.join(&relative))?;
2274                copied.push(relative);
2275            }
2276
2277            let manifest = crate::backup::BackupManifest::create(&stage, epoch, &copied)?;
2278            manifest.write(&stage)?;
2279            crate::backup::sync_directories(&stage)?;
2280            if destination.exists() {
2281                return Err(MongrelError::Conflict(format!(
2282                    "backup destination already exists: {}",
2283                    destination.display()
2284                )));
2285            }
2286            std::fs::rename(&stage, &destination)?;
2287            std::fs::File::open(&parent)?.sync_all()?;
2288            Ok(crate::backup::BackupReport {
2289                destination,
2290                epoch,
2291                files: manifest.files.len(),
2292                bytes: manifest.total_bytes(),
2293            })
2294        })();
2295
2296        if outcome.is_err() && stage.exists() {
2297            let _ = std::fs::remove_dir_all(&stage);
2298        }
2299        outcome
2300    }
2301
2302    /// Return complete committed transactions after `since_epoch`. A gap or a
2303    /// transaction backed by a spilled run requires a fresh bootstrap image.
2304    pub fn replication_batch_since(
2305        &self,
2306        since_epoch: u64,
2307    ) -> Result<crate::replication::ReplicationBatch> {
2308        use crate::wal::Op;
2309
2310        let mut wal = self.shared_wal.lock();
2311        wal.group_sync()?;
2312        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
2313        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
2314        drop(wal);
2315
2316        let commits: HashMap<u64, u64> = records
2317            .iter()
2318            .filter_map(|record| match &record.op {
2319                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
2320                _ => None,
2321            })
2322            .collect();
2323        let earliest_epoch = commits.values().copied().min();
2324        let current_epoch = commits
2325            .values()
2326            .copied()
2327            .max()
2328            .unwrap_or(0)
2329            .max(self.visible_epoch().0);
2330        let selected: HashSet<u64> = commits
2331            .iter()
2332            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
2333            .collect();
2334        let retention_gap = since_epoch < current_epoch
2335            && earliest_epoch.map_or(true, |epoch| epoch > since_epoch.saturating_add(1));
2336        let spilled = records.iter().any(|record| {
2337            selected.contains(&record.txn_id)
2338                && matches!(
2339                    &record.op,
2340                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
2341                )
2342        });
2343        let records = records
2344            .into_iter()
2345            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
2346            .filter(|record| selected.contains(&record.txn_id))
2347            .collect();
2348        Ok(crate::replication::ReplicationBatch {
2349            current_epoch,
2350            earliest_epoch,
2351            requires_snapshot: retention_gap || spilled,
2352            records,
2353        })
2354    }
2355
2356    /// Durably append a leader batch to a follower's local WAL. The caller
2357    /// must drop and reopen this handle to run ordinary WAL recovery before it
2358    /// advances `_meta/repl_epoch`.
2359    pub fn append_replication_batch(&self, records: &[crate::wal::Record]) -> Result<u64> {
2360        use crate::wal::Op;
2361
2362        if !self.read_only {
2363            return Err(MongrelError::InvalidArgument(
2364                "replication batches may only target a marked replica".into(),
2365            ));
2366        }
2367        let current = crate::replication::replica_epoch(&self.root)?;
2368        let mut commits = HashMap::new();
2369        let mut commit_timestamps = HashMap::new();
2370        for record in records {
2371            match &record.op {
2372                Op::TxnCommit { epoch, added_runs } => {
2373                    if !added_runs.is_empty() {
2374                        return Err(MongrelError::Conflict(
2375                            "replication snapshot required for spilled-run transaction".into(),
2376                        ));
2377                    }
2378                    if commits.insert(record.txn_id, *epoch).is_some() {
2379                        return Err(MongrelError::InvalidArgument(format!(
2380                            "duplicate commit for replication transaction {}",
2381                            record.txn_id
2382                        )));
2383                    }
2384                }
2385                Op::CommitTimestamp { unix_nanos } => {
2386                    commit_timestamps.insert(record.txn_id, *unix_nanos);
2387                }
2388                _ => {}
2389            }
2390        }
2391        for record in records {
2392            if record.txn_id != crate::wal::SYSTEM_TXN_ID
2393                && !matches!(&record.op, Op::TxnAbort)
2394                && !commits.contains_key(&record.txn_id)
2395            {
2396                return Err(MongrelError::InvalidArgument(format!(
2397                    "incomplete replication transaction {}",
2398                    record.txn_id
2399                )));
2400            }
2401        }
2402        let target_epoch = commits
2403            .values()
2404            .copied()
2405            .filter(|epoch| *epoch > current)
2406            .max()
2407            .unwrap_or(current);
2408        let mut selected: HashSet<u64> = commits
2409            .iter()
2410            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
2411            .collect();
2412        if selected.is_empty() {
2413            return Ok(current);
2414        }
2415        let mut wal = self.shared_wal.lock();
2416        wal.group_sync()?;
2417        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
2418        let existing: HashSet<(u64, u64)> =
2419            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
2420                .into_iter()
2421                .filter_map(|record| match record.op {
2422                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
2423                    _ => None,
2424                })
2425                .collect();
2426        selected.retain(|txn_id| {
2427            commits
2428                .get(txn_id)
2429                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
2430        });
2431        for record in records {
2432            if !selected.contains(&record.txn_id) {
2433                continue;
2434            }
2435            match &record.op {
2436                Op::TxnCommit { epoch, added_runs } => {
2437                    let timestamp = commit_timestamps
2438                        .get(&record.txn_id)
2439                        .copied()
2440                        .unwrap_or_else(current_unix_nanos);
2441                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
2442                }
2443                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
2444                op => {
2445                    wal.append(record.txn_id, 0, op.clone())?;
2446                }
2447            }
2448        }
2449        if !selected.is_empty() {
2450            wal.group_sync()?;
2451        }
2452        Ok(target_epoch)
2453    }
2454
2455    /// Resolve a table name → id (live tables only). pub(crate) so the
2456    /// transaction layer can stage by name.
2457    pub fn table_id(&self, name: &str) -> Result<u64> {
2458        let cat = self.catalog.read();
2459        cat.live(name)
2460            .map(|e| e.table_id)
2461            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
2462    }
2463
2464    pub fn procedures(&self) -> Vec<StoredProcedure> {
2465        self.catalog
2466            .read()
2467            .procedures
2468            .iter()
2469            .map(|p| p.procedure.clone())
2470            .collect()
2471    }
2472
2473    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
2474        self.catalog
2475            .read()
2476            .procedures
2477            .iter()
2478            .find(|p| p.procedure.name == name)
2479            .map(|p| p.procedure.clone())
2480    }
2481
2482    pub fn create_procedure(&self, mut procedure: StoredProcedure) -> Result<StoredProcedure> {
2483        self.require(&crate::auth::Permission::Ddl)?;
2484        let _g = self.ddl_lock.lock();
2485        procedure.validate()?;
2486        self.validate_procedure_references(&procedure)?;
2487        {
2488            let cat = self.catalog.read();
2489            if cat
2490                .procedures
2491                .iter()
2492                .any(|p| p.procedure.name == procedure.name)
2493            {
2494                return Err(MongrelError::InvalidArgument(format!(
2495                    "procedure {:?} already exists",
2496                    procedure.name
2497                )));
2498            }
2499        }
2500        let commit_lock = Arc::clone(&self.commit_lock);
2501        let _c = commit_lock.lock();
2502        let epoch = self.epoch.bump_assigned();
2503        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2504        procedure.created_epoch = epoch.0;
2505        procedure.updated_epoch = epoch.0;
2506        {
2507            let mut cat = self.catalog.write();
2508            cat.procedures.push(ProcedureEntry::from(procedure.clone()));
2509            cat.db_epoch = epoch.0;
2510        }
2511        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2512        self.epoch.publish_in_order(epoch);
2513        _epoch_guard.disarm();
2514        Ok(procedure)
2515    }
2516
2517    pub fn create_or_replace_procedure(
2518        &self,
2519        procedure: StoredProcedure,
2520    ) -> Result<StoredProcedure> {
2521        let _g = self.ddl_lock.lock();
2522        procedure.validate()?;
2523        self.validate_procedure_references(&procedure)?;
2524        let commit_lock = Arc::clone(&self.commit_lock);
2525        let _c = commit_lock.lock();
2526        let epoch = self.epoch.bump_assigned();
2527        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2528        let replaced = {
2529            let mut cat = self.catalog.write();
2530            let next = match cat
2531                .procedures
2532                .iter()
2533                .position(|p| p.procedure.name == procedure.name)
2534            {
2535                Some(idx) => {
2536                    let next = cat.procedures[idx]
2537                        .procedure
2538                        .replaced(procedure.clone(), epoch.0)?;
2539                    cat.procedures[idx] = ProcedureEntry::from(next.clone());
2540                    next
2541                }
2542                None => {
2543                    let mut next = procedure;
2544                    next.created_epoch = epoch.0;
2545                    next.updated_epoch = epoch.0;
2546                    cat.procedures.push(ProcedureEntry::from(next.clone()));
2547                    next
2548                }
2549            };
2550            cat.db_epoch = epoch.0;
2551            next
2552        };
2553        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2554        self.epoch.publish_in_order(epoch);
2555        _epoch_guard.disarm();
2556        Ok(replaced)
2557    }
2558
2559    pub fn drop_procedure(&self, name: &str) -> Result<()> {
2560        self.require(&crate::auth::Permission::Ddl)?;
2561        let _g = self.ddl_lock.lock();
2562        let commit_lock = Arc::clone(&self.commit_lock);
2563        let _c = commit_lock.lock();
2564        let epoch = self.epoch.bump_assigned();
2565        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2566        {
2567            let mut cat = self.catalog.write();
2568            let before = cat.procedures.len();
2569            cat.procedures.retain(|p| p.procedure.name != name);
2570            if cat.procedures.len() == before {
2571                return Err(MongrelError::NotFound(format!(
2572                    "procedure {name:?} not found"
2573                )));
2574            }
2575            cat.db_epoch = epoch.0;
2576        }
2577        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2578        self.epoch.publish_in_order(epoch);
2579        _epoch_guard.disarm();
2580        Ok(())
2581    }
2582
2583    // ── User / role / credentials management ─────────────────────────────
2584
2585    /// List all catalog users (password hashes included — callers should not
2586    /// serialize them externally).
2587    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
2588        self.catalog.read().users.clone()
2589    }
2590
2591    /// List all catalog roles.
2592    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
2593        self.catalog.read().roles.clone()
2594    }
2595
2596    /// Create a new user with an Argon2id-hashed password.
2597    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
2598        self.require(&crate::auth::Permission::Admin)?;
2599        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
2600        let epoch = self.epoch.bump_assigned();
2601        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2602        let id = {
2603            let mut cat = self.catalog.write();
2604            if cat.users.iter().any(|u| u.username == username) {
2605                return Err(MongrelError::InvalidArgument(format!(
2606                    "user {username:?} already exists"
2607                )));
2608            }
2609            cat.next_user_id += 1;
2610            let id = cat.next_user_id;
2611            let entry = crate::auth::UserEntry {
2612                id,
2613                username: username.into(),
2614                password_hash: hash,
2615                roles: Vec::new(),
2616                is_admin: false,
2617                created_epoch: epoch.0,
2618            };
2619            cat.users.push(entry.clone());
2620            cat.security_version = cat.security_version.wrapping_add(1);
2621            cat.db_epoch = epoch.0;
2622            entry
2623        };
2624        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2625        self.epoch.publish_in_order(epoch);
2626        _epoch_guard.disarm();
2627        Ok(id)
2628    }
2629
2630    /// Drop a user by username.
2631    pub fn drop_user(&self, username: &str) -> Result<()> {
2632        self.require(&crate::auth::Permission::Admin)?;
2633        let epoch = self.epoch.bump_assigned();
2634        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2635        {
2636            let mut cat = self.catalog.write();
2637            let before = cat.users.len();
2638            cat.users.retain(|u| u.username != username);
2639            if cat.users.len() == before {
2640                return Err(MongrelError::NotFound(format!(
2641                    "user {username:?} not found"
2642                )));
2643            }
2644            cat.security_version = cat.security_version.wrapping_add(1);
2645            cat.db_epoch = epoch.0;
2646        }
2647        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2648        self.epoch.publish_in_order(epoch);
2649        _epoch_guard.disarm();
2650        Ok(())
2651    }
2652
2653    /// Change a user's password.
2654    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
2655        self.require(&crate::auth::Permission::Admin)?;
2656        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
2657        let epoch = self.epoch.bump_assigned();
2658        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2659        {
2660            let mut cat = self.catalog.write();
2661            let user = cat
2662                .users
2663                .iter_mut()
2664                .find(|u| u.username == username)
2665                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
2666            user.password_hash = hash;
2667            cat.security_version = cat.security_version.wrapping_add(1);
2668            cat.db_epoch = epoch.0;
2669        }
2670        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2671        self.epoch.publish_in_order(epoch);
2672        _epoch_guard.disarm();
2673        Ok(())
2674    }
2675
2676    /// Verify credentials. Returns `Some(entry)` on success, `None` on
2677    /// mismatch, `Err` on engine error.
2678    pub fn verify_user(
2679        &self,
2680        username: &str,
2681        password: &str,
2682    ) -> Result<Option<crate::auth::UserEntry>> {
2683        let cat = self.catalog.read();
2684        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
2685            return Ok(None);
2686        };
2687        if user.password_hash.is_empty() {
2688            return Ok(None);
2689        }
2690        let ok = crate::auth::verify_password(password, &user.password_hash)
2691            .map_err(MongrelError::Other)?;
2692        if ok {
2693            Ok(Some(user.clone()))
2694        } else {
2695            Ok(None)
2696        }
2697    }
2698
2699    /// Grant admin privileges to a user (bypasses all permission checks).
2700    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
2701        self.require(&crate::auth::Permission::Admin)?;
2702        let epoch = self.epoch.bump_assigned();
2703        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2704        {
2705            let mut cat = self.catalog.write();
2706            let user = cat
2707                .users
2708                .iter_mut()
2709                .find(|u| u.username == username)
2710                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
2711            user.is_admin = is_admin;
2712            cat.security_version = cat.security_version.wrapping_add(1);
2713            cat.db_epoch = epoch.0;
2714        }
2715        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2716        self.epoch.publish_in_order(epoch);
2717        _epoch_guard.disarm();
2718        Ok(())
2719    }
2720
2721    /// Create a new role.
2722    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
2723        self.require(&crate::auth::Permission::Admin)?;
2724        let epoch = self.epoch.bump_assigned();
2725        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2726        let entry = {
2727            let mut cat = self.catalog.write();
2728            if cat.roles.iter().any(|r| r.name == name) {
2729                return Err(MongrelError::InvalidArgument(format!(
2730                    "role {name:?} already exists"
2731                )));
2732            }
2733            let entry = crate::auth::RoleEntry {
2734                name: name.into(),
2735                permissions: Vec::new(),
2736                created_epoch: epoch.0,
2737            };
2738            cat.roles.push(entry.clone());
2739            cat.security_version = cat.security_version.wrapping_add(1);
2740            cat.db_epoch = epoch.0;
2741            entry
2742        };
2743        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2744        self.epoch.publish_in_order(epoch);
2745        _epoch_guard.disarm();
2746        Ok(entry)
2747    }
2748
2749    /// Drop a role by name.
2750    pub fn drop_role(&self, name: &str) -> Result<()> {
2751        self.require(&crate::auth::Permission::Admin)?;
2752        let epoch = self.epoch.bump_assigned();
2753        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2754        {
2755            let mut cat = self.catalog.write();
2756            let before = cat.roles.len();
2757            cat.roles.retain(|r| r.name != name);
2758            if cat.roles.len() == before {
2759                return Err(MongrelError::NotFound(format!("role {name:?} not found")));
2760            }
2761            // Remove the role from all users.
2762            for user in &mut cat.users {
2763                user.roles.retain(|r| r != name);
2764            }
2765            cat.security_version = cat.security_version.wrapping_add(1);
2766            cat.db_epoch = epoch.0;
2767        }
2768        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2769        self.epoch.publish_in_order(epoch);
2770        _epoch_guard.disarm();
2771        Ok(())
2772    }
2773
2774    /// Grant a role to a user.
2775    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
2776        self.require(&crate::auth::Permission::Admin)?;
2777        let epoch = self.epoch.bump_assigned();
2778        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2779        {
2780            let mut cat = self.catalog.write();
2781            if !cat.roles.iter().any(|r| r.name == role_name) {
2782                return Err(MongrelError::NotFound(format!(
2783                    "role {role_name:?} not found"
2784                )));
2785            }
2786            let user = cat
2787                .users
2788                .iter_mut()
2789                .find(|u| u.username == username)
2790                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
2791            if !user.roles.contains(&role_name.to_string()) {
2792                user.roles.push(role_name.into());
2793            }
2794            cat.security_version = cat.security_version.wrapping_add(1);
2795            cat.db_epoch = epoch.0;
2796        }
2797        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2798        self.epoch.publish_in_order(epoch);
2799        _epoch_guard.disarm();
2800        Ok(())
2801    }
2802
2803    /// Revoke a role from a user.
2804    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
2805        self.require(&crate::auth::Permission::Admin)?;
2806        let epoch = self.epoch.bump_assigned();
2807        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2808        {
2809            let mut cat = self.catalog.write();
2810            let user = cat
2811                .users
2812                .iter_mut()
2813                .find(|u| u.username == username)
2814                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
2815            user.roles.retain(|r| r != role_name);
2816            cat.security_version = cat.security_version.wrapping_add(1);
2817            cat.db_epoch = epoch.0;
2818        }
2819        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2820        self.epoch.publish_in_order(epoch);
2821        _epoch_guard.disarm();
2822        Ok(())
2823    }
2824
2825    /// Grant a permission to a role.
2826    pub fn grant_permission(
2827        &self,
2828        role_name: &str,
2829        permission: crate::auth::Permission,
2830    ) -> Result<()> {
2831        self.require(&crate::auth::Permission::Admin)?;
2832        let epoch = self.epoch.bump_assigned();
2833        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2834        {
2835            let mut cat = self.catalog.write();
2836            let role = cat
2837                .roles
2838                .iter_mut()
2839                .find(|r| r.name == role_name)
2840                .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
2841            merge_permission(&mut role.permissions, permission);
2842            cat.security_version = cat.security_version.wrapping_add(1);
2843            cat.db_epoch = epoch.0;
2844        }
2845        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2846        self.epoch.publish_in_order(epoch);
2847        _epoch_guard.disarm();
2848        Ok(())
2849    }
2850
2851    /// Revoke a permission from a role.
2852    pub fn revoke_permission(
2853        &self,
2854        role_name: &str,
2855        permission: crate::auth::Permission,
2856    ) -> Result<()> {
2857        self.require(&crate::auth::Permission::Admin)?;
2858        let epoch = self.epoch.bump_assigned();
2859        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2860        {
2861            let mut cat = self.catalog.write();
2862            let role = cat
2863                .roles
2864                .iter_mut()
2865                .find(|r| r.name == role_name)
2866                .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
2867            revoke_permission_from(&mut role.permissions, &permission);
2868            cat.security_version = cat.security_version.wrapping_add(1);
2869            cat.db_epoch = epoch.0;
2870        }
2871        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2872        self.epoch.publish_in_order(epoch);
2873        _epoch_guard.disarm();
2874        Ok(())
2875    }
2876
2877    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
2878    /// permissions from their roles. Returns `None` if the user doesn't exist.
2879    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
2880        let cat = self.catalog.read();
2881        Self::resolve_principal_from_catalog(&cat, username)
2882    }
2883
2884    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
2885    /// without needing a constructed `Database`. Used by the credentialed open
2886    /// path (which must verify credentials before the `Database` exists) and
2887    /// by [`resolve_principal`](Self::resolve_principal).
2888    fn resolve_principal_from_catalog(
2889        cat: &Catalog,
2890        username: &str,
2891    ) -> Option<crate::auth::Principal> {
2892        let user = cat.users.iter().find(|u| u.username == username)?;
2893        let mut permissions = Vec::new();
2894        for role_name in &user.roles {
2895            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
2896                permissions.extend(role.permissions.iter().cloned());
2897            }
2898        }
2899        Some(crate::auth::Principal {
2900            username: user.username.clone(),
2901            is_admin: user.is_admin,
2902            roles: user.roles.clone(),
2903            permissions,
2904        })
2905    }
2906
2907    /// Check whether a user has a specific permission (via their roles).
2908    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
2909        match self.resolve_principal(username) {
2910            Some(p) => p.has_permission(permission),
2911            None => false,
2912        }
2913    }
2914
2915    /// Returns `true` if this database's catalog has `require_auth = true`.
2916    /// When true, every operation consults the cached [`Principal`] via
2917    /// [`require`](Self::require).
2918    pub fn require_auth_enabled(&self) -> bool {
2919        self.catalog.read().require_auth
2920    }
2921
2922    /// A snapshot of the cached principal for this handle, if any. `None` for
2923    /// databases opened without credentials (the default). Returns a clone
2924    /// because the principal lives behind an `RwLock`.
2925    pub fn principal(&self) -> Option<crate::auth::Principal> {
2926        self.principal.read().clone()
2927    }
2928
2929    /// Build a `TableAuthChecker` from the current auth state. Used when
2930    /// mounting a new table (`create_table`) so the table inherits the
2931    /// database's enforcement configuration. The checker reads the live
2932    /// `require_auth` flag and cached principal, so changes via `enable_auth`
2933    /// / `refresh_principal` propagate to already-mounted tables.
2934    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
2935        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
2936            self.auth_state.clone(),
2937        )))
2938    }
2939
2940    /// Re-resolve the cached principal from the on-disk catalog. Long-lived
2941    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
2942    /// possibly made by a different handle to the same database — to pick up
2943    /// the new effective permissions without re-verifying the password.
2944    ///
2945    /// This reloads the catalog from disk first, so changes committed by other
2946    /// handles (or other processes) are visible. The username is taken from
2947    /// the existing cached principal; if the user has since been dropped,
2948    /// returns [`MongrelError::InvalidCredentials`].
2949    ///
2950    /// No-op (returns `Ok(())`) on a credentialless database, or on a
2951    /// credentialed database whose cached principal is `None`.
2952    pub fn refresh_principal(&self) -> Result<()> {
2953        let username = match self.principal.read().clone() {
2954            Some(p) => p.username,
2955            None => return Ok(()),
2956        };
2957        // Reload the catalog from disk so role/permission changes made by
2958        // other handles (or processes) are reflected. The in-memory catalog
2959        // is only updated by mutations on *this* handle.
2960        let cat = catalog::read(&self.root, self.meta_dek.as_ref())?
2961            .ok_or_else(|| MongrelError::NotFound("catalog vanished during refresh".into()))?;
2962        // Swap in the reloaded catalog so subsequent operations on this handle
2963        // also see the updated permissions/roles.
2964        *self.catalog.write() = cat.clone();
2965        match Self::resolve_principal_from_catalog(&cat, &username) {
2966            Some(p) => {
2967                *self.principal.write() = Some(p.clone());
2968                // Update the shared auth state so mounted Tables see the new
2969                // permissions immediately (Tables read from AuthState, not from
2970                // self.principal).
2971                self.auth_state.set_principal(Some(p));
2972                Ok(())
2973            }
2974            None => Err(MongrelError::InvalidCredentials { username }),
2975        }
2976    }
2977
2978    /// Convert a credentialless database to a credentialed one: create the
2979    /// first admin user, set `require_auth = true`, and cache the admin
2980    /// principal on this handle so subsequent operations on the same handle
2981    /// continue to work. After this call, the database can only be reopened
2982    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
2983    ///
2984    /// Refuses if the database already has `require_auth = true`. This is
2985    /// the conversion path for existing databases; for fresh databases,
2986    /// `create_with_credentials` sets everything up atomically.
2987    ///
2988    /// See `docs/15-credential-enforcement.md`.
2989    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
2990        let password_hash =
2991            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
2992        let epoch = self.epoch.bump_assigned();
2993        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2994        {
2995            let mut cat = self.catalog.write();
2996            if cat.require_auth {
2997                return Err(MongrelError::InvalidArgument(
2998                    "database already has require_auth enabled".into(),
2999                ));
3000            }
3001            // Reject a duplicate username so the bootstrap doesn't silently
3002            // shadow an existing user.
3003            if cat.users.iter().any(|u| u.username == admin_username) {
3004                return Err(MongrelError::InvalidArgument(format!(
3005                    "user {admin_username:?} already exists"
3006                )));
3007            }
3008            cat.next_user_id = cat.next_user_id.max(1);
3009            let id = cat.next_user_id;
3010            cat.next_user_id += 1;
3011            cat.users.push(crate::auth::UserEntry {
3012                id,
3013                username: admin_username.to_string(),
3014                password_hash,
3015                roles: Vec::new(),
3016                is_admin: true,
3017                created_epoch: epoch.0,
3018            });
3019            cat.require_auth = true;
3020            cat.security_version = cat.security_version.wrapping_add(1);
3021            cat.db_epoch = epoch.0;
3022        }
3023        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3024        // Cache the admin principal on this handle + update the shared auth
3025        // state so mounted tables start enforcing immediately.
3026        *self.principal.write() = Some(crate::auth::Principal {
3027            username: admin_username.to_string(),
3028            is_admin: true,
3029            roles: Vec::new(),
3030            permissions: Vec::new(),
3031        });
3032        self.auth_state.set_require_auth(true);
3033        self.epoch.publish_in_order(epoch);
3034        _epoch_guard.disarm();
3035        Ok(())
3036    }
3037
3038    /// Disable `require_auth` on this database, reverting it to credentialless
3039    /// mode. This is the **recovery** path — it requires the handle to already
3040    /// be open (and therefore already authenticated if `require_auth` was on).
3041    ///
3042    /// After this call, the database can be reopened with plain
3043    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
3044    /// credentials. All existing users and roles are preserved in the catalog
3045    /// (so `require_auth` can be re-enabled without recreating them), but they
3046    /// are no longer consulted for enforcement.
3047    ///
3048    /// For true **offline** recovery (when credentials are lost and no
3049    /// authenticated handle is available), the caller opens the database
3050    /// directly via the catalog file (filesystem access required) and calls
3051    /// this method — see the CLI's `auth disable-offline` command.
3052    ///
3053    /// See `docs/15-credential-enforcement.md` §4.7.
3054    pub fn disable_auth(&self) -> Result<()> {
3055        let epoch = self.epoch.bump_assigned();
3056        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3057        {
3058            let mut cat = self.catalog.write();
3059            if !cat.require_auth {
3060                return Err(MongrelError::InvalidArgument(
3061                    "database does not have require_auth enabled".into(),
3062                ));
3063            }
3064            cat.require_auth = false;
3065            cat.security_version = cat.security_version.wrapping_add(1);
3066            cat.db_epoch = epoch.0;
3067        }
3068        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3069        // Clear the cached principal — enforcement is now off.
3070        *self.principal.write() = None;
3071        // Update the shared auth state so mounted tables also stop enforcing.
3072        self.auth_state.set_require_auth(false);
3073        self.epoch.publish_in_order(epoch);
3074        _epoch_guard.disarm();
3075        Ok(())
3076    }
3077
3078    /// Enforcement check: if the catalog has `require_auth = true`, verify
3079    /// that the cached principal satisfies `perm`. Called by every
3080    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
3081    /// Table/Transaction/MongrelSession operations).
3082    ///
3083    /// On a credentialless database this is a no-op (`Ok(())`).
3084    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
3085        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
3086            return Err(MongrelError::ReadOnlyReplica);
3087        }
3088        if !self.catalog.read().require_auth {
3089            return Ok(());
3090        }
3091        let guard = self.principal.read();
3092        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
3093        if p.has_permission(perm) {
3094            Ok(())
3095        } else {
3096            Err(MongrelError::PermissionDenied {
3097                required: perm.clone(),
3098                principal: p.username.clone(),
3099            })
3100        }
3101    }
3102
3103    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
3104    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
3105    /// other callers that know the operation kind + table name but don't want
3106    /// to construct the full `Permission` enum value themselves.
3107    pub fn require_table(
3108        &self,
3109        table: &str,
3110        perm: crate::auth_state::RequiredPermission,
3111    ) -> Result<()> {
3112        self.require(&perm.into_permission(table))
3113    }
3114
3115    pub fn triggers(&self) -> Vec<StoredTrigger> {
3116        self.catalog
3117            .read()
3118            .triggers
3119            .iter()
3120            .map(|t| t.trigger.clone())
3121            .collect()
3122    }
3123
3124    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
3125        self.catalog
3126            .read()
3127            .triggers
3128            .iter()
3129            .find(|t| t.trigger.name == name)
3130            .map(|t| t.trigger.clone())
3131    }
3132
3133    pub fn create_trigger(&self, mut trigger: StoredTrigger) -> Result<StoredTrigger> {
3134        self.require(&crate::auth::Permission::Ddl)?;
3135        let _g = self.ddl_lock.lock();
3136        trigger.validate()?;
3137        self.validate_trigger_references(&trigger)?;
3138        {
3139            let cat = self.catalog.read();
3140            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
3141                return Err(MongrelError::InvalidArgument(format!(
3142                    "trigger {:?} already exists",
3143                    trigger.name
3144                )));
3145            }
3146        }
3147        let commit_lock = Arc::clone(&self.commit_lock);
3148        let _c = commit_lock.lock();
3149        let epoch = self.epoch.bump_assigned();
3150        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3151        trigger.created_epoch = epoch.0;
3152        trigger.updated_epoch = epoch.0;
3153        {
3154            let mut cat = self.catalog.write();
3155            cat.triggers.push(TriggerEntry::from(trigger.clone()));
3156            cat.db_epoch = epoch.0;
3157        }
3158        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3159        self.epoch.publish_in_order(epoch);
3160        _epoch_guard.disarm();
3161        Ok(trigger)
3162    }
3163
3164    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
3165        let _g = self.ddl_lock.lock();
3166        trigger.validate()?;
3167        self.validate_trigger_references(&trigger)?;
3168        let commit_lock = Arc::clone(&self.commit_lock);
3169        let _c = commit_lock.lock();
3170        let epoch = self.epoch.bump_assigned();
3171        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3172        let replaced = {
3173            let mut cat = self.catalog.write();
3174            let next = match cat
3175                .triggers
3176                .iter()
3177                .position(|t| t.trigger.name == trigger.name)
3178            {
3179                Some(idx) => {
3180                    let next = cat.triggers[idx]
3181                        .trigger
3182                        .replaced(trigger.clone(), epoch.0)?;
3183                    cat.triggers[idx] = TriggerEntry::from(next.clone());
3184                    next
3185                }
3186                None => {
3187                    let mut next = trigger;
3188                    next.created_epoch = epoch.0;
3189                    next.updated_epoch = epoch.0;
3190                    cat.triggers.push(TriggerEntry::from(next.clone()));
3191                    next
3192                }
3193            };
3194            cat.db_epoch = epoch.0;
3195            next
3196        };
3197        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3198        self.epoch.publish_in_order(epoch);
3199        _epoch_guard.disarm();
3200        Ok(replaced)
3201    }
3202
3203    pub fn drop_trigger(&self, name: &str) -> Result<()> {
3204        self.require(&crate::auth::Permission::Ddl)?;
3205        let _g = self.ddl_lock.lock();
3206        let commit_lock = Arc::clone(&self.commit_lock);
3207        let _c = commit_lock.lock();
3208        let epoch = self.epoch.bump_assigned();
3209        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3210        {
3211            let mut cat = self.catalog.write();
3212            let before = cat.triggers.len();
3213            cat.triggers.retain(|t| t.trigger.name != name);
3214            if cat.triggers.len() == before {
3215                return Err(MongrelError::NotFound(format!(
3216                    "trigger {name:?} not found"
3217                )));
3218            }
3219            cat.db_epoch = epoch.0;
3220        }
3221        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3222        self.epoch.publish_in_order(epoch);
3223        _epoch_guard.disarm();
3224        Ok(())
3225    }
3226
3227    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
3228        self.catalog.read().external_tables.clone()
3229    }
3230
3231    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
3232        self.catalog
3233            .read()
3234            .external_tables
3235            .iter()
3236            .find(|t| t.name == name)
3237            .cloned()
3238    }
3239
3240    pub fn create_external_table(
3241        &self,
3242        mut entry: ExternalTableEntry,
3243    ) -> Result<ExternalTableEntry> {
3244        self.require(&crate::auth::Permission::Ddl)?;
3245        let _g = self.ddl_lock.lock();
3246        entry.validate()?;
3247        {
3248            let cat = self.catalog.read();
3249            if cat.live(&entry.name).is_some()
3250                || cat.external_tables.iter().any(|t| t.name == entry.name)
3251            {
3252                return Err(MongrelError::InvalidArgument(format!(
3253                    "table {:?} already exists",
3254                    entry.name
3255                )));
3256            }
3257        }
3258        let commit_lock = Arc::clone(&self.commit_lock);
3259        let _c = commit_lock.lock();
3260        let epoch = self.epoch.bump_assigned();
3261        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3262        entry.created_epoch = epoch.0;
3263        {
3264            let mut cat = self.catalog.write();
3265            cat.external_tables.push(entry.clone());
3266            cat.db_epoch = epoch.0;
3267        }
3268        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3269        self.epoch.publish_in_order(epoch);
3270        _epoch_guard.disarm();
3271        Ok(entry)
3272    }
3273
3274    pub fn drop_external_table(&self, name: &str) -> Result<()> {
3275        self.require(&crate::auth::Permission::Ddl)?;
3276        let _g = self.ddl_lock.lock();
3277        let commit_lock = Arc::clone(&self.commit_lock);
3278        let _c = commit_lock.lock();
3279        let epoch = self.epoch.bump_assigned();
3280        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3281        {
3282            let mut cat = self.catalog.write();
3283            let before = cat.external_tables.len();
3284            cat.external_tables.retain(|t| t.name != name);
3285            if cat.external_tables.len() == before {
3286                return Err(MongrelError::NotFound(format!(
3287                    "external table {name:?} not found"
3288                )));
3289            }
3290            cat.db_epoch = epoch.0;
3291        }
3292        let state_dir = self.root.join(VTAB_DIR).join(name);
3293        if state_dir.exists() {
3294            std::fs::remove_dir_all(state_dir)?;
3295        }
3296        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3297        self.epoch.publish_in_order(epoch);
3298        _epoch_guard.disarm();
3299        Ok(())
3300    }
3301
3302    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
3303        let txn_id = self.alloc_txn_id();
3304        self.commit_transaction_with_external_states(
3305            txn_id,
3306            self.epoch.visible(),
3307            Vec::new(),
3308            vec![(name.to_string(), state.to_vec())],
3309            Vec::new(),
3310            None,
3311            None,
3312        )
3313    }
3314
3315    pub fn trigger_config(&self) -> TriggerConfig {
3316        use std::sync::atomic::Ordering;
3317        TriggerConfig {
3318            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
3319            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
3320            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
3321        }
3322    }
3323
3324    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
3325        use std::sync::atomic::Ordering;
3326        if config.max_depth == 0 {
3327            return Err(MongrelError::InvalidArgument(
3328                "trigger max_depth must be greater than 0".into(),
3329            ));
3330        }
3331        self.trigger_recursive
3332            .store(config.recursive_triggers, Ordering::Relaxed);
3333        self.trigger_max_depth
3334            .store(config.max_depth, Ordering::Relaxed);
3335        self.trigger_max_loop_iterations
3336            .store(config.max_loop_iterations, Ordering::Relaxed);
3337        Ok(())
3338    }
3339
3340    pub fn set_recursive_triggers(&self, recursive: bool) {
3341        use std::sync::atomic::Ordering;
3342        self.trigger_recursive.store(recursive, Ordering::Relaxed);
3343    }
3344
3345    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
3346    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
3347    /// as a low-latency wake-up.
3348    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
3349        self.notify.subscribe()
3350    }
3351
3352    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
3353        self.change_wake.subscribe()
3354    }
3355
3356    /// Reconstruct committed row changes from the retained shared WAL. Event
3357    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
3358    /// resumes before the oldest retained commit receives `gap = true` and
3359    /// must rebootstrap instead of silently skipping changes.
3360    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
3361        use crate::wal::Op;
3362
3363        let resume = match last_event_id {
3364            Some(id) => {
3365                let (epoch, index) = id.split_once(':').ok_or_else(|| {
3366                    MongrelError::InvalidArgument(format!(
3367                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
3368                    ))
3369                })?;
3370                Some((
3371                    epoch.parse::<u64>().map_err(|error| {
3372                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
3373                    })?,
3374                    index.parse::<u32>().map_err(|error| {
3375                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
3376                    })?,
3377                ))
3378            }
3379            None => None,
3380        };
3381
3382        let mut wal = self.shared_wal.lock();
3383        wal.group_sync()?;
3384        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
3385        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
3386        drop(wal);
3387
3388        let commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = records
3389            .iter()
3390            .filter_map(|record| match &record.op {
3391                Op::TxnCommit { epoch, added_runs } => {
3392                    Some((record.txn_id, (*epoch, added_runs.clone())))
3393                }
3394                _ => None,
3395            })
3396            .collect();
3397        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
3398        let current_epoch = self.epoch.visible().0;
3399        let gap = resume.is_some_and(|(epoch, _)| {
3400            epoch < current_epoch
3401                && earliest_epoch.map_or(true, |earliest| earliest > epoch.saturating_add(1))
3402        });
3403        if gap {
3404            return Ok(CdcBatch {
3405                events: Vec::new(),
3406                current_epoch,
3407                earliest_epoch,
3408                gap: true,
3409            });
3410        }
3411
3412        let table_names: HashMap<u64, String> = self
3413            .catalog
3414            .read()
3415            .tables
3416            .iter()
3417            .map(|entry| (entry.table_id, entry.name.clone()))
3418            .collect();
3419        let before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = records
3420            .iter()
3421            .filter_map(|record| {
3422                if !commits.contains_key(&record.txn_id) {
3423                    return None;
3424                }
3425                let Op::BeforeImage {
3426                    table_id,
3427                    row_id,
3428                    row,
3429                } = &record.op
3430                else {
3431                    return None;
3432                };
3433                bincode::deserialize(row)
3434                    .ok()
3435                    .map(|before| ((record.txn_id, *table_id, row_id.0), before))
3436            })
3437            .collect();
3438        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
3439        let mut events = Vec::new();
3440        for record in &records {
3441            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
3442                continue;
3443            };
3444            let event = match &record.op {
3445                Op::Put { table_id, rows } => {
3446                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
3447                    let data = serde_json::to_value(rows)
3448                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
3449                    Some((*table_id, "put", data))
3450                }
3451                Op::Delete { table_id, row_ids } => {
3452                    let before = row_ids
3453                        .iter()
3454                        .filter_map(|row_id| {
3455                            before_images
3456                                .get(&(record.txn_id, *table_id, row_id.0))
3457                                .cloned()
3458                        })
3459                        .collect::<Vec<_>>();
3460                    Some((
3461                        *table_id,
3462                        "delete",
3463                        serde_json::json!({
3464                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
3465                            "before": before,
3466                        }),
3467                    ))
3468                }
3469                Op::TruncateTable { table_id } => {
3470                    Some((*table_id, "truncate", serde_json::Value::Null))
3471                }
3472                _ => None,
3473            };
3474            if let Some((table_id, op, data)) = event {
3475                let index = operation_indices.entry(record.txn_id).or_insert(0);
3476                let event_position = (*commit_epoch, *index);
3477                *index = index.saturating_add(1);
3478                if resume.is_some_and(|position| event_position <= position) {
3479                    continue;
3480                }
3481                events.push(ChangeEvent {
3482                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
3483                    channel: "changes".into(),
3484                    table_id: Some(table_id),
3485                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
3486                    op: op.into(),
3487                    epoch: *commit_epoch,
3488                    txn_id: Some(record.txn_id),
3489                    message: None,
3490                    data: Some(data),
3491                });
3492            }
3493            if let Op::TxnCommit { added_runs, .. } = &record.op {
3494                for run in added_runs {
3495                    let index = operation_indices.entry(record.txn_id).or_insert(0);
3496                    let event_position = (*commit_epoch, *index);
3497                    *index = index.saturating_add(1);
3498                    if resume.is_some_and(|position| event_position <= position) {
3499                        continue;
3500                    }
3501                    let handle = self.tables.read().get(&run.table_id).cloned();
3502                    let rows = handle.and_then(|handle| {
3503                        let table = handle.lock();
3504                        let mut reader = table.open_reader(run.run_id).ok()?;
3505                        let mut rows = reader.all_rows().ok()?;
3506                        for row in &mut rows {
3507                            row.committed_epoch = Epoch(*commit_epoch);
3508                        }
3509                        Some(rows)
3510                    });
3511                    let Some(rows) = rows else {
3512                        // Spilled transactions keep row payloads in an immutable
3513                        // run instead of duplicating them in the WAL. If that run
3514                        // was already compacted/reaped, resuming cannot provide a
3515                        // complete row image and must fail closed.
3516                        return Ok(CdcBatch {
3517                            events: Vec::new(),
3518                            current_epoch,
3519                            earliest_epoch,
3520                            gap: true,
3521                        });
3522                    };
3523                    events.push(ChangeEvent {
3524                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
3525                        channel: "changes".into(),
3526                        table_id: Some(run.table_id),
3527                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
3528                        op: "put_run".into(),
3529                        epoch: *commit_epoch,
3530                        txn_id: Some(record.txn_id),
3531                        message: None,
3532                        data: Some(serde_json::json!({
3533                            "run_id": run.run_id.to_string(),
3534                            "row_count": run.row_count,
3535                            "min_row_id": run.min_row_id,
3536                            "max_row_id": run.max_row_id,
3537                            "rows": rows,
3538                        })),
3539                    });
3540                }
3541            }
3542        }
3543        Ok(CdcBatch {
3544            events,
3545            current_epoch,
3546            earliest_epoch,
3547            gap: false,
3548        })
3549    }
3550
3551    /// Publish a notification message on a named channel. Reaches all active
3552    /// subscribers (daemon `/events`, application listeners).
3553    pub fn notify(&self, channel: &str, message: Option<String>) {
3554        let _ = self.notify.send(ChangeEvent {
3555            id: None,
3556            channel: channel.to_string(),
3557            table_id: None,
3558            table: String::new(),
3559            op: "notify".into(),
3560            epoch: self.epoch.visible().0,
3561            txn_id: None,
3562            message,
3563            data: None,
3564        });
3565    }
3566
3567    pub fn call_procedure(
3568        &self,
3569        name: &str,
3570        args: HashMap<String, crate::Value>,
3571    ) -> Result<ProcedureCallResult> {
3572        self.call_procedure_as(name, args, None)
3573    }
3574
3575    pub fn call_procedure_as(
3576        &self,
3577        name: &str,
3578        args: HashMap<String, crate::Value>,
3579        principal: Option<&crate::auth::Principal>,
3580    ) -> Result<ProcedureCallResult> {
3581        // v1 requires ALL to call procedures on a require_auth database; a
3582        // finer SECURITY DEFINER-style marker is a future extension (spec §9
3583        // decision 1).
3584        self.require_for(principal, &crate::auth::Permission::All)?;
3585        let procedure = self
3586            .procedure(name)
3587            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
3588        let args = bind_procedure_args(&procedure, args)?;
3589        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
3590        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
3591        if has_writes {
3592            let mut tx = self.begin_as(principal.cloned());
3593            let run = (|| {
3594                for step in &procedure.body.steps {
3595                    let output = self.execute_procedure_step(
3596                        step,
3597                        &args,
3598                        &outputs,
3599                        Some(&mut tx),
3600                        principal,
3601                    )?;
3602                    outputs.insert(step.id().to_string(), output);
3603                }
3604                eval_return_output(&procedure.body.return_value, &args, &outputs)
3605            })();
3606            match run {
3607                Ok(output) => {
3608                    let epoch = tx.commit()?.0;
3609                    Ok(ProcedureCallResult {
3610                        epoch: Some(epoch),
3611                        output,
3612                    })
3613                }
3614                Err(e) => {
3615                    tx.rollback();
3616                    Err(e)
3617                }
3618            }
3619        } else {
3620            for step in &procedure.body.steps {
3621                let output = self.execute_procedure_step(step, &args, &outputs, None, principal)?;
3622                outputs.insert(step.id().to_string(), output);
3623            }
3624            Ok(ProcedureCallResult {
3625                epoch: None,
3626                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
3627            })
3628        }
3629    }
3630
3631    fn execute_procedure_step(
3632        &self,
3633        step: &ProcedureStep,
3634        args: &HashMap<String, crate::Value>,
3635        outputs: &HashMap<String, ProcedureCallOutput>,
3636        tx: Option<&mut crate::txn::Transaction<'_>>,
3637        principal: Option<&crate::auth::Principal>,
3638    ) -> Result<ProcedureCallOutput> {
3639        match step {
3640            ProcedureStep::NativeQuery {
3641                table,
3642                conditions,
3643                projection,
3644                limit,
3645                ..
3646            } => {
3647                let mut q = crate::Query::new();
3648                for condition in conditions {
3649                    q = q.and(eval_condition(condition, args, outputs)?);
3650                }
3651                let handle = self.table(table)?;
3652                let rows = handle.lock().query(&q)?;
3653                let mut rows = self.secure_rows_for(table, rows, principal)?;
3654                if let Some(limit) = limit {
3655                    rows.truncate(*limit);
3656                }
3657                let projection = projection.as_ref();
3658                Ok(ProcedureCallOutput::Rows(
3659                    rows.into_iter()
3660                        .map(|row| ProcedureCallRow {
3661                            row_id: Some(row.row_id),
3662                            columns: match projection {
3663                                Some(ids) => row
3664                                    .columns
3665                                    .into_iter()
3666                                    .filter(|(id, _)| ids.contains(id))
3667                                    .collect(),
3668                                None => row.columns,
3669                            },
3670                        })
3671                        .collect(),
3672                ))
3673            }
3674            ProcedureStep::Put {
3675                table,
3676                cells,
3677                returning,
3678                ..
3679            } => {
3680                let tx = tx.ok_or_else(|| {
3681                    MongrelError::InvalidArgument(
3682                        "write procedure step requires a transaction".into(),
3683                    )
3684                })?;
3685                let cells = eval_cells(cells, args, outputs)?;
3686                if *returning {
3687                    let out = tx.put_returning(table, cells)?;
3688                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
3689                        row_id: None,
3690                        columns: out.row.columns.into_iter().collect(),
3691                    }))
3692                } else {
3693                    tx.put(table, cells)?;
3694                    Ok(ProcedureCallOutput::Null)
3695                }
3696            }
3697            ProcedureStep::Upsert {
3698                table,
3699                cells,
3700                update_cells,
3701                returning,
3702                ..
3703            } => {
3704                let tx = tx.ok_or_else(|| {
3705                    MongrelError::InvalidArgument(
3706                        "write procedure step requires a transaction".into(),
3707                    )
3708                })?;
3709                let cells = eval_cells(cells, args, outputs)?;
3710                let action = match update_cells {
3711                    Some(update_cells) => {
3712                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
3713                    }
3714                    None => crate::UpsertAction::DoNothing,
3715                };
3716                let out = tx.upsert(table, cells, action)?;
3717                if *returning {
3718                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
3719                        row_id: None,
3720                        columns: out.row.columns.into_iter().collect(),
3721                    }))
3722                } else {
3723                    Ok(ProcedureCallOutput::Null)
3724                }
3725            }
3726            ProcedureStep::DeleteByPk { table, pk, .. } => {
3727                let tx = tx.ok_or_else(|| {
3728                    MongrelError::InvalidArgument(
3729                        "write procedure step requires a transaction".into(),
3730                    )
3731                })?;
3732                let pk = eval_value(pk, args, outputs)?;
3733                let handle = self.table(table)?;
3734                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
3735                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
3736                })?;
3737                tx.delete(table, row_id)?;
3738                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
3739            }
3740            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
3741                "DeleteRows procedure step is not supported by the core executor yet".into(),
3742            )),
3743            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
3744                "SqlQuery procedure step must be executed by mongreldb-query".into(),
3745            )),
3746        }
3747    }
3748
3749    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
3750        let cat = self.catalog.read();
3751        for step in &procedure.body.steps {
3752            let Some(table_name) = step.table() else {
3753                continue;
3754            };
3755            let schema = &cat
3756                .live(table_name)
3757                .ok_or_else(|| {
3758                    MongrelError::InvalidArgument(format!(
3759                        "procedure {:?} references unknown table {table_name:?}",
3760                        procedure.name
3761                    ))
3762                })?
3763                .schema;
3764            match step {
3765                ProcedureStep::NativeQuery {
3766                    conditions,
3767                    projection,
3768                    ..
3769                } => {
3770                    for condition in conditions {
3771                        validate_condition_columns(condition, schema)?;
3772                    }
3773                    if let Some(projection) = projection {
3774                        for id in projection {
3775                            validate_column_id(*id, schema)?;
3776                        }
3777                    }
3778                }
3779                ProcedureStep::Put { cells, .. } => {
3780                    for cell in cells {
3781                        validate_column_id(cell.column_id, schema)?;
3782                    }
3783                }
3784                ProcedureStep::Upsert {
3785                    cells,
3786                    update_cells,
3787                    ..
3788                } => {
3789                    for cell in cells {
3790                        validate_column_id(cell.column_id, schema)?;
3791                    }
3792                    if let Some(update_cells) = update_cells {
3793                        for cell in update_cells {
3794                            validate_column_id(cell.column_id, schema)?;
3795                        }
3796                    }
3797                }
3798                ProcedureStep::DeleteByPk { .. } => {
3799                    if schema.primary_key().is_none() {
3800                        return Err(MongrelError::InvalidArgument(format!(
3801                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
3802                            procedure.name
3803                        )));
3804                    }
3805                }
3806                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
3807            }
3808        }
3809        Ok(())
3810    }
3811
3812    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
3813        let cat = self.catalog.read();
3814        let target_schema = match &trigger.target {
3815            TriggerTarget::Table(target_name) => cat
3816                .live(target_name)
3817                .ok_or_else(|| {
3818                    MongrelError::InvalidArgument(format!(
3819                        "trigger {:?} references unknown target table {target_name:?}",
3820                        trigger.name
3821                    ))
3822                })?
3823                .schema
3824                .clone(),
3825            TriggerTarget::View(_) => Schema {
3826                columns: trigger.target_columns.clone(),
3827                ..Schema::default()
3828            },
3829        };
3830        for col in &trigger.update_of {
3831            if target_schema.column(col).is_none() {
3832                return Err(MongrelError::InvalidArgument(format!(
3833                    "trigger {:?} UPDATE OF references unknown column {col:?}",
3834                    trigger.name
3835                )));
3836            }
3837        }
3838        if let Some(expr) = &trigger.when {
3839            validate_trigger_expr(expr, &target_schema, trigger.event)?;
3840        }
3841        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
3842        for step in &trigger.program.steps {
3843            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
3844            {
3845                return Err(MongrelError::InvalidArgument(
3846                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
3847                ));
3848            }
3849            validate_trigger_step(
3850                step,
3851                &cat,
3852                &target_schema,
3853                trigger.event,
3854                &mut select_schemas,
3855            )?;
3856        }
3857        Ok(())
3858    }
3859
3860    /// Begin a new transaction reading at the current visible epoch.
3861    pub fn begin(&self) -> crate::txn::Transaction<'_> {
3862        self.begin_with_isolation(crate::txn::IsolationLevel::default())
3863    }
3864
3865    pub fn begin_as(
3866        &self,
3867        principal: Option<crate::auth::Principal>,
3868    ) -> crate::txn::Transaction<'_> {
3869        let txn_id = self.alloc_txn_id();
3870        let read = Snapshot::at(self.epoch.visible());
3871        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal)
3872    }
3873
3874    /// Begin a transaction with a specific isolation level.
3875    pub fn begin_with_isolation(
3876        &self,
3877        level: crate::txn::IsolationLevel,
3878    ) -> crate::txn::Transaction<'_> {
3879        let txn_id = self.alloc_txn_id();
3880        let epoch = match level {
3881            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
3882            _ => self.epoch.visible(),
3883        };
3884        let read = Snapshot::at(epoch);
3885        crate::txn::Transaction::new(self, txn_id, read)
3886    }
3887
3888    /// Begin a transaction whose trigger programs may route external-table DML
3889    /// through an application/query-layer module bridge.
3890    pub fn begin_with_external_trigger_bridge<'a>(
3891        &'a self,
3892        bridge: &'a dyn ExternalTriggerBridge,
3893    ) -> crate::txn::Transaction<'a> {
3894        let txn_id = self.alloc_txn_id();
3895        let read = Snapshot::at(self.epoch.visible());
3896        crate::txn::Transaction::new(self, txn_id, read).with_external_trigger_bridge(bridge)
3897    }
3898
3899    pub fn begin_with_external_trigger_bridge_as<'a>(
3900        &'a self,
3901        bridge: &'a dyn ExternalTriggerBridge,
3902        principal: Option<crate::auth::Principal>,
3903    ) -> crate::txn::Transaction<'a> {
3904        let txn_id = self.alloc_txn_id();
3905        let read = Snapshot::at(self.epoch.visible());
3906        crate::txn::Transaction::new(self, txn_id, read)
3907            .with_external_trigger_bridge(bridge)
3908            .with_principal(principal)
3909    }
3910
3911    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
3912    pub fn transaction<T>(
3913        &self,
3914        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
3915    ) -> Result<T> {
3916        let mut tx = self.begin();
3917        match f(&mut tx) {
3918            Ok(out) => {
3919                tx.commit()?;
3920                Ok(out)
3921            }
3922            Err(e) => {
3923                tx.rollback();
3924                Err(e)
3925            }
3926        }
3927    }
3928
3929    /// Run `f` in a transaction with an external-trigger bridge; commit on
3930    /// `Ok`, rollback on `Err`.
3931    pub fn transaction_with_external_trigger_bridge<'a, T>(
3932        &'a self,
3933        bridge: &'a dyn ExternalTriggerBridge,
3934        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
3935    ) -> Result<T> {
3936        let mut tx = self.begin_with_external_trigger_bridge(bridge);
3937        match f(&mut tx) {
3938            Ok(out) => {
3939                tx.commit()?;
3940                Ok(out)
3941            }
3942            Err(e) => {
3943                tx.rollback();
3944                Err(e)
3945            }
3946        }
3947    }
3948
3949    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
3950        &'a self,
3951        bridge: &'a dyn ExternalTriggerBridge,
3952        principal: Option<crate::auth::Principal>,
3953        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
3954    ) -> Result<T> {
3955        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
3956        match f(&mut tx) {
3957            Ok(output) => {
3958                tx.commit()?;
3959                Ok(output)
3960            }
3961            Err(error) => {
3962                tx.rollback();
3963                Err(error)
3964            }
3965        }
3966    }
3967
3968    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
3969    /// `Transaction::new` so registration happens **before** any read.
3970    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
3971        self.active_txns.register(epoch)
3972    }
3973
3974    fn fill_auto_increment_for_staging(
3975        &self,
3976        staging: &mut [(u64, crate::txn::Staged)],
3977    ) -> Result<()> {
3978        let tables = self.tables.read();
3979        for (table_id, staged) in staging {
3980            if let crate::txn::Staged::Put(cells) = staged {
3981                if let Some(handle) = tables.get(table_id) {
3982                    let mut t = handle.lock();
3983                    t.fill_auto_inc(cells)?;
3984                }
3985            }
3986        }
3987        Ok(())
3988    }
3989
3990    fn expand_table_triggers(
3991        &self,
3992        staging: &mut Vec<(u64, crate::txn::Staged)>,
3993        read_epoch: Epoch,
3994        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
3995        external_states: &mut Vec<(String, Vec<u8>)>,
3996    ) -> Result<()> {
3997        let mut external_writes = Vec::new();
3998        let config = self.trigger_config();
3999        if config.recursive_triggers {
4000            let chunk = std::mem::take(staging);
4001            let stacks = vec![Vec::new(); chunk.len()];
4002            *staging = self.expand_trigger_chunk(
4003                chunk,
4004                stacks,
4005                read_epoch,
4006                0,
4007                config.max_depth,
4008                &mut external_writes,
4009                &config,
4010            )?;
4011            self.apply_external_trigger_writes(
4012                external_writes,
4013                external_trigger_bridge,
4014                external_states,
4015                staging,
4016            )?;
4017            return Ok(());
4018        }
4019
4020        let mut expansion = self.expand_table_triggers_once(staging, read_epoch, None, &config)?;
4021        if !expansion.before.is_empty() {
4022            let mut final_staging = expansion.before;
4023            final_staging.extend(filter_ignored_staging(
4024                std::mem::take(staging),
4025                &expansion.ignored_indices,
4026            ));
4027            *staging = final_staging;
4028        } else if !expansion.ignored_indices.is_empty() {
4029            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
4030        }
4031        staging.append(&mut expansion.after);
4032        external_writes.append(&mut expansion.before_external);
4033        external_writes.append(&mut expansion.after_external);
4034        self.apply_external_trigger_writes(
4035            external_writes,
4036            external_trigger_bridge,
4037            external_states,
4038            staging,
4039        )?;
4040        Ok(())
4041    }
4042
4043    #[allow(clippy::too_many_arguments)]
4044    fn expand_trigger_chunk(
4045        &self,
4046        mut chunk: Vec<(u64, crate::txn::Staged)>,
4047        stacks: Vec<Vec<String>>,
4048        read_epoch: Epoch,
4049        depth: u32,
4050        max_depth: u32,
4051        external_writes: &mut Vec<ExternalTriggerWrite>,
4052        config: &TriggerConfig,
4053    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
4054        if chunk.is_empty() {
4055            return Ok(Vec::new());
4056        }
4057        self.fill_auto_increment_for_staging(&mut chunk)?;
4058        let expansion =
4059            self.expand_table_triggers_once(&mut chunk, read_epoch, Some(&stacks), config)?;
4060        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
4061            let stack = expansion
4062                .before_stacks
4063                .first()
4064                .or_else(|| expansion.after_stacks.first())
4065                .cloned()
4066                .unwrap_or_default();
4067            return Err(MongrelError::Conflict(format!(
4068                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
4069                Self::format_trigger_stack(&stack)
4070            )));
4071        }
4072
4073        let mut out = Vec::new();
4074        external_writes.extend(expansion.before_external);
4075        out.extend(self.expand_trigger_chunk(
4076            expansion.before,
4077            expansion.before_stacks,
4078            read_epoch,
4079            depth + 1,
4080            max_depth,
4081            external_writes,
4082            config,
4083        )?);
4084        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
4085        external_writes.extend(expansion.after_external);
4086        out.extend(self.expand_trigger_chunk(
4087            expansion.after,
4088            expansion.after_stacks,
4089            read_epoch,
4090            depth + 1,
4091            max_depth,
4092            external_writes,
4093            config,
4094        )?);
4095        Ok(out)
4096    }
4097
4098    fn apply_external_trigger_writes(
4099        &self,
4100        writes: Vec<ExternalTriggerWrite>,
4101        bridge: Option<&dyn ExternalTriggerBridge>,
4102        external_states: &mut Vec<(String, Vec<u8>)>,
4103        staging: &mut Vec<(u64, crate::txn::Staged)>,
4104    ) -> Result<()> {
4105        if writes.is_empty() {
4106            return Ok(());
4107        }
4108        let bridge = bridge.ok_or_else(|| {
4109            MongrelError::InvalidArgument(
4110                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
4111            )
4112        })?;
4113        for write in writes {
4114            let table = write.table().to_string();
4115            let entry = self.external_table(&table).ok_or_else(|| {
4116                MongrelError::NotFound(format!("external table {table:?} not found"))
4117            })?;
4118            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
4119            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
4120            external_states.push((table, result.state));
4121            for base_write in result.base_writes {
4122                match base_write {
4123                    ExternalTriggerBaseWrite::Put { table, cells } => {
4124                        let table_id = self.table_id(&table)?;
4125                        staging.push((table_id, crate::txn::Staged::Put(cells)));
4126                    }
4127                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
4128                        let table_id = self.table_id(&table)?;
4129                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
4130                    }
4131                }
4132            }
4133        }
4134        dedup_external_states_in_place(external_states);
4135        Ok(())
4136    }
4137
4138    fn expand_table_triggers_once(
4139        &self,
4140        staging: &mut Vec<(u64, crate::txn::Staged)>,
4141        read_epoch: Epoch,
4142        trigger_stacks: Option<&[Vec<String>]>,
4143        config: &TriggerConfig,
4144    ) -> Result<TriggerExpansion> {
4145        let triggers: Vec<StoredTrigger> = self
4146            .catalog
4147            .read()
4148            .triggers
4149            .iter()
4150            .filter(|entry| {
4151                entry.trigger.enabled
4152                    && matches!(
4153                        entry.trigger.timing,
4154                        TriggerTiming::Before | TriggerTiming::After
4155                    )
4156                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
4157            })
4158            .map(|entry| entry.trigger.clone())
4159            .collect();
4160        if triggers.is_empty() || staging.is_empty() {
4161            return Ok(TriggerExpansion::default());
4162        }
4163
4164        let before_triggers = triggers
4165            .iter()
4166            .filter(|trigger| trigger.timing == TriggerTiming::Before)
4167            .cloned()
4168            .collect::<Vec<_>>();
4169        let after_triggers = triggers
4170            .iter()
4171            .filter(|trigger| trigger.timing == TriggerTiming::After)
4172            .cloned()
4173            .collect::<Vec<_>>();
4174
4175        let mut before_added = Vec::new();
4176        let mut before_stacks = Vec::new();
4177        let mut before_external = Vec::new();
4178        let mut ignored_indices = std::collections::BTreeSet::new();
4179        if !before_triggers.is_empty() {
4180            let before_events =
4181                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?;
4182            let mut out = TriggerProgramOutput {
4183                added: &mut before_added,
4184                added_stacks: &mut before_stacks,
4185                added_external: &mut before_external,
4186                ignored_indices: &mut ignored_indices,
4187            };
4188            self.execute_triggers_for_events(
4189                &before_triggers,
4190                &before_events,
4191                Some(staging),
4192                &mut out,
4193                config,
4194                read_epoch,
4195            )?;
4196        }
4197
4198        let after_events = if after_triggers.is_empty() {
4199            Vec::new()
4200        } else {
4201            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?
4202                .into_iter()
4203                .filter(|event| {
4204                    !event
4205                        .op_indices
4206                        .iter()
4207                        .any(|idx| ignored_indices.contains(idx))
4208                })
4209                .collect()
4210        };
4211
4212        let mut after_added = Vec::new();
4213        let mut after_stacks = Vec::new();
4214        let mut after_external = Vec::new();
4215        let mut out = TriggerProgramOutput {
4216            added: &mut after_added,
4217            added_stacks: &mut after_stacks,
4218            added_external: &mut after_external,
4219            ignored_indices: &mut ignored_indices,
4220        };
4221        self.execute_triggers_for_events(
4222            &after_triggers,
4223            &after_events,
4224            None,
4225            &mut out,
4226            config,
4227            read_epoch,
4228        )?;
4229        Ok(TriggerExpansion {
4230            before: before_added,
4231            before_stacks,
4232            before_external,
4233            after: after_added,
4234            after_stacks,
4235            after_external,
4236            ignored_indices,
4237        })
4238    }
4239
4240    fn execute_triggers_for_events(
4241        &self,
4242        triggers: &[StoredTrigger],
4243        events: &[WriteEvent],
4244        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
4245        out: &mut TriggerProgramOutput<'_>,
4246        config: &TriggerConfig,
4247        read_epoch: Epoch,
4248    ) -> Result<()> {
4249        for event in events {
4250            for trigger in triggers {
4251                if event
4252                    .op_indices
4253                    .iter()
4254                    .any(|idx| out.ignored_indices.contains(idx))
4255                {
4256                    break;
4257                }
4258                let matches = {
4259                    let cat = self.catalog.read();
4260                    trigger_matches_event(trigger, event, &cat)?
4261                };
4262                if !matches {
4263                    continue;
4264                }
4265                if let Some(when) = &trigger.when {
4266                    if !eval_trigger_expr(when, event)? {
4267                        continue;
4268                    }
4269                }
4270                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
4271                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
4272                    return Err(MongrelError::Conflict(format!(
4273                        "trigger recursion cycle detected; trigger stack: {}",
4274                        Self::format_trigger_stack(&trigger_stack)
4275                    )));
4276                }
4277                let outcome = match staging.as_mut() {
4278                    Some(staging) => self.execute_trigger_program(
4279                        trigger,
4280                        event,
4281                        Some(&mut **staging),
4282                        out,
4283                        &trigger_stack,
4284                        config,
4285                        read_epoch,
4286                    )?,
4287                    None => self.execute_trigger_program(
4288                        trigger,
4289                        event,
4290                        None,
4291                        out,
4292                        &trigger_stack,
4293                        config,
4294                        read_epoch,
4295                    )?,
4296                };
4297                if outcome == TriggerProgramOutcome::Ignore {
4298                    out.ignored_indices.extend(event.op_indices.iter().copied());
4299                    break;
4300                }
4301            }
4302        }
4303        Ok(())
4304    }
4305
4306    fn trigger_events_for_staging(
4307        &self,
4308        staging: &[(u64, crate::txn::Staged)],
4309        read_epoch: Epoch,
4310        trigger_stacks: Option<&[Vec<String>]>,
4311    ) -> Result<Vec<WriteEvent>> {
4312        use crate::txn::Staged;
4313        use std::collections::{HashMap, VecDeque};
4314
4315        let snapshot = Snapshot::at(read_epoch);
4316        let cat = self.catalog.read();
4317        let mut table_names = HashMap::new();
4318        let mut table_schemas = HashMap::new();
4319        for entry in cat
4320            .tables
4321            .iter()
4322            .filter(|entry| matches!(entry.state, TableState::Live))
4323        {
4324            table_names.insert(entry.table_id, entry.name.clone());
4325            table_schemas.insert(entry.table_id, entry.schema.clone());
4326        }
4327        drop(cat);
4328
4329        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
4330        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
4331        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
4332
4333        for (idx, (table_id, staged)) in staging.iter().enumerate() {
4334            let Some(schema) = table_schemas.get(table_id) else {
4335                continue;
4336            };
4337            let Some(pk) = schema.primary_key() else {
4338                continue;
4339            };
4340            match staged {
4341                Staged::Delete(row_id) => {
4342                    let handle = self.table_by_id(*table_id)?;
4343                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
4344                        continue;
4345                    };
4346                    let Some(pk_value) = row.columns.get(&pk.id) else {
4347                        continue;
4348                    };
4349                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
4350                    delete_by_key
4351                        .entry((*table_id, pk_value.encode_key()))
4352                        .or_default()
4353                        .push_back(idx);
4354                }
4355                Staged::Put(cells) => {
4356                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
4357                        put_by_key
4358                            .entry((*table_id, value.encode_key()))
4359                            .or_default()
4360                            .push_back(idx);
4361                    }
4362                }
4363                Staged::Update(row_id, _) => {
4364                    let handle = self.table_by_id(*table_id)?;
4365                    let row = handle.lock().get(*row_id, snapshot);
4366                    if let Some(row) = row {
4367                        old_rows.insert(idx, TriggerRowImage::from_row(row));
4368                    }
4369                }
4370                Staged::Truncate => {}
4371            }
4372        }
4373
4374        let mut paired_delete = std::collections::HashSet::new();
4375        let mut paired_put = std::collections::HashSet::new();
4376        let mut events = Vec::new();
4377
4378        for (key, deletes) in delete_by_key.iter_mut() {
4379            let Some(puts) = put_by_key.get_mut(key) else {
4380                continue;
4381            };
4382            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
4383                paired_delete.insert(delete_idx);
4384                paired_put.insert(put_idx);
4385                let (table_id, _) = &staging[put_idx];
4386                let Some(table_name) = table_names.get(table_id).cloned() else {
4387                    continue;
4388                };
4389                let old = old_rows.get(&delete_idx).cloned();
4390                let new = match &staging[put_idx].1 {
4391                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
4392                    _ => None,
4393                };
4394                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
4395                events.push(WriteEvent {
4396                    table: table_name,
4397                    kind: TriggerEvent::Update,
4398                    old,
4399                    new,
4400                    changed_columns,
4401                    op_indices: vec![delete_idx, put_idx],
4402                    put_idx: Some(put_idx),
4403                    trigger_stack: Self::trigger_stack_for_indices(
4404                        trigger_stacks,
4405                        &[delete_idx, put_idx],
4406                    ),
4407                });
4408            }
4409        }
4410
4411        for (idx, (table_id, staged)) in staging.iter().enumerate() {
4412            let Some(table_name) = table_names.get(table_id).cloned() else {
4413                continue;
4414            };
4415            match staged {
4416                Staged::Put(cells) if !paired_put.contains(&idx) => {
4417                    let new = Some(TriggerRowImage::from_cells(cells));
4418                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
4419                    events.push(WriteEvent {
4420                        table: table_name,
4421                        kind: TriggerEvent::Insert,
4422                        old: None,
4423                        new,
4424                        changed_columns,
4425                        op_indices: vec![idx],
4426                        put_idx: Some(idx),
4427                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
4428                    });
4429                }
4430                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
4431                    let old = match old_rows.get(&idx).cloned() {
4432                        Some(old) => Some(old),
4433                        None => {
4434                            let handle = self.table_by_id(*table_id)?;
4435                            let row = handle.lock().get(*row_id, snapshot);
4436                            row.map(TriggerRowImage::from_row)
4437                        }
4438                    };
4439                    let Some(old) = old else {
4440                        continue;
4441                    };
4442                    let changed_columns = old.columns.keys().copied().collect();
4443                    events.push(WriteEvent {
4444                        table: table_name,
4445                        kind: TriggerEvent::Delete,
4446                        old: Some(old),
4447                        new: None,
4448                        changed_columns,
4449                        op_indices: vec![idx],
4450                        put_idx: None,
4451                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
4452                    });
4453                }
4454                Staged::Update(_, cells) => {
4455                    let old = old_rows.get(&idx).cloned();
4456                    let new = Some(TriggerRowImage::from_cells(cells));
4457                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
4458                    events.push(WriteEvent {
4459                        table: table_name,
4460                        kind: TriggerEvent::Update,
4461                        old,
4462                        new,
4463                        changed_columns,
4464                        op_indices: vec![idx],
4465                        put_idx: Some(idx),
4466                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
4467                    });
4468                }
4469                Staged::Truncate => {}
4470                _ => {}
4471            }
4472        }
4473
4474        Ok(events)
4475    }
4476
4477    #[allow(clippy::too_many_arguments)]
4478    fn execute_trigger_program(
4479        &self,
4480        trigger: &StoredTrigger,
4481        event: &WriteEvent,
4482        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
4483        out: &mut TriggerProgramOutput<'_>,
4484        trigger_stack: &[String],
4485        config: &TriggerConfig,
4486        read_epoch: Epoch,
4487    ) -> Result<TriggerProgramOutcome> {
4488        let mut event = event.clone();
4489        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
4490        self.execute_trigger_steps(
4491            trigger,
4492            &trigger.program.steps,
4493            &mut event,
4494            staging,
4495            out,
4496            trigger_stack,
4497            config,
4498            &mut select_results,
4499            0,
4500            None,
4501            read_epoch,
4502        )
4503    }
4504
4505    #[allow(clippy::too_many_arguments)]
4506    fn execute_trigger_steps(
4507        &self,
4508        trigger: &StoredTrigger,
4509        steps: &[TriggerStep],
4510        event: &mut WriteEvent,
4511        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
4512        out: &mut TriggerProgramOutput<'_>,
4513        trigger_stack: &[String],
4514        config: &TriggerConfig,
4515        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
4516        depth: u32,
4517        selected: Option<&TriggerRowImage>,
4518        read_epoch: Epoch,
4519    ) -> Result<TriggerProgramOutcome> {
4520        let _ = depth;
4521        for step in steps {
4522            match step {
4523                TriggerStep::SetNew { cells } => {
4524                    if trigger.timing != TriggerTiming::Before {
4525                        return Err(MongrelError::InvalidArgument(
4526                            "SetNew trigger step is only valid in BEFORE triggers".into(),
4527                        ));
4528                    }
4529                    let put_idx = event.put_idx.ok_or_else(|| {
4530                        MongrelError::InvalidArgument(
4531                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
4532                        )
4533                    })?;
4534                    let staging = staging.as_deref_mut().ok_or_else(|| {
4535                        MongrelError::InvalidArgument(
4536                            "SetNew trigger step requires mutable trigger staging".into(),
4537                        )
4538                    })?;
4539                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
4540                        Some(crate::txn::Staged::Put(cells))
4541                        | Some(crate::txn::Staged::Update(_, cells)) => cells,
4542                        _ => {
4543                            return Err(MongrelError::InvalidArgument(
4544                                "SetNew trigger step target row is not mutable".into(),
4545                            ))
4546                        }
4547                    };
4548                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
4549                        row_cells.retain(|(id, _)| *id != column_id);
4550                        row_cells.push((column_id, value.clone()));
4551                        if let Some(new) = &mut event.new {
4552                            new.columns.insert(column_id, value);
4553                        }
4554                    }
4555                    row_cells.sort_by_key(|(id, _)| *id);
4556                }
4557                TriggerStep::Insert { table, cells } => {
4558                    let cells = eval_trigger_cells(cells, event, selected)?;
4559                    if let Ok(table_id) = self.table_id(table) {
4560                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
4561                        out.added_stacks.push(trigger_stack.to_vec());
4562                    } else if self.external_table(table).is_some() {
4563                        out.added_external.push(ExternalTriggerWrite::Insert {
4564                            table: table.clone(),
4565                            cells,
4566                        });
4567                    } else {
4568                        return Err(MongrelError::NotFound(format!(
4569                            "trigger {:?} insert target {table:?} not found",
4570                            trigger.name
4571                        )));
4572                    }
4573                }
4574                TriggerStep::UpdateByPk { table, pk, cells } => {
4575                    let pk = eval_trigger_value(pk, event, selected)?;
4576                    let cells = eval_trigger_cells(cells, event, selected)?;
4577                    if self.external_table(table).is_some() {
4578                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
4579                            table: table.clone(),
4580                            pk,
4581                            cells,
4582                        });
4583                    } else {
4584                        let row_id = self
4585                            .table(table)?
4586                            .lock()
4587                            .lookup_pk(&pk.encode_key())
4588                            .ok_or_else(|| {
4589                                MongrelError::NotFound(format!(
4590                                    "trigger {:?} update target not found",
4591                                    trigger.name
4592                                ))
4593                            })?;
4594                        let handle = self.table(table)?;
4595                        let snapshot = Snapshot::at(self.epoch.visible());
4596                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
4597                            MongrelError::NotFound(format!(
4598                                "trigger {:?} update target not visible",
4599                                trigger.name
4600                            ))
4601                        })?;
4602                        let mut merged = old.columns;
4603                        for (column_id, value) in cells {
4604                            merged.insert(column_id, value);
4605                        }
4606                        out.added.push((
4607                            self.table_id(table)?,
4608                            crate::txn::Staged::Update(row_id, merged.into_iter().collect()),
4609                        ));
4610                        out.added_stacks.push(trigger_stack.to_vec());
4611                    }
4612                }
4613                TriggerStep::DeleteByPk { table, pk } => {
4614                    let pk = eval_trigger_value(pk, event, selected)?;
4615                    if self.external_table(table).is_some() {
4616                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
4617                            table: table.clone(),
4618                            pk,
4619                        });
4620                    } else {
4621                        let row_id = self
4622                            .table(table)?
4623                            .lock()
4624                            .lookup_pk(&pk.encode_key())
4625                            .ok_or_else(|| {
4626                                MongrelError::NotFound(format!(
4627                                    "trigger {:?} delete target not found",
4628                                    trigger.name
4629                                ))
4630                            })?;
4631                        out.added
4632                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
4633                        out.added_stacks.push(trigger_stack.to_vec());
4634                    }
4635                }
4636                TriggerStep::Select {
4637                    id,
4638                    table,
4639                    conditions,
4640                } => {
4641                    let schema = self.table(table)?.lock().schema().clone();
4642                    let snapshot = Snapshot::at(read_epoch);
4643                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
4644                    let mut matched = Vec::new();
4645                    for row in rows {
4646                        let image = TriggerRowImage::from_row(row);
4647                        let passes = conditions
4648                            .iter()
4649                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
4650                            .collect::<Result<Vec<_>>>()?
4651                            .into_iter()
4652                            .all(|b| b);
4653                        if passes {
4654                            matched.push(image);
4655                        }
4656                    }
4657                    if let Some(pk) = schema.primary_key() {
4658                        matched.sort_by(|a, b| {
4659                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
4660                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
4661                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
4662                        });
4663                    }
4664                    select_results.insert(id.clone(), matched);
4665                }
4666                TriggerStep::Foreach { id, steps } => {
4667                    let rows = select_results.get(id).ok_or_else(|| {
4668                        MongrelError::InvalidArgument(format!(
4669                            "trigger {:?} foreach references unknown select id {id:?}",
4670                            trigger.name
4671                        ))
4672                    })?;
4673                    if rows.len() > config.max_loop_iterations as usize {
4674                        return Err(MongrelError::InvalidArgument(format!(
4675                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
4676                            trigger.name, config.max_loop_iterations
4677                        )));
4678                    }
4679                    for row in rows.clone() {
4680                        let result = self.execute_trigger_steps(
4681                            trigger,
4682                            steps,
4683                            event,
4684                            staging.as_deref_mut(),
4685                            out,
4686                            trigger_stack,
4687                            config,
4688                            select_results,
4689                            depth + 1,
4690                            Some(&row),
4691                            read_epoch,
4692                        )?;
4693                        if result == TriggerProgramOutcome::Ignore {
4694                            return Ok(TriggerProgramOutcome::Ignore);
4695                        }
4696                    }
4697                }
4698                TriggerStep::DeleteWhere { table, conditions } => {
4699                    let schema = self.table(table)?.lock().schema().clone();
4700                    let snapshot = Snapshot::at(read_epoch);
4701                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
4702                    let table_id = self.table_id(table)?;
4703                    let mut to_delete = Vec::new();
4704                    for row in rows {
4705                        let image = TriggerRowImage::from_row(row.clone());
4706                        let passes = conditions
4707                            .iter()
4708                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
4709                            .collect::<Result<Vec<_>>>()?
4710                            .into_iter()
4711                            .all(|b| b);
4712                        if passes {
4713                            to_delete.push((table_id, row.row_id));
4714                        }
4715                    }
4716                    for (table_id, row_id) in to_delete {
4717                        out.added
4718                            .push((table_id, crate::txn::Staged::Delete(row_id)));
4719                        out.added_stacks.push(trigger_stack.to_vec());
4720                    }
4721                }
4722                TriggerStep::UpdateWhere {
4723                    table,
4724                    conditions,
4725                    cells,
4726                } => {
4727                    let schema = self.table(table)?.lock().schema().clone();
4728                    let snapshot = Snapshot::at(read_epoch);
4729                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
4730                    let table_id = self.table_id(table)?;
4731                    let mut to_update = Vec::new();
4732                    for row in rows {
4733                        let image = TriggerRowImage::from_row(row.clone());
4734                        let passes = conditions
4735                            .iter()
4736                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
4737                            .collect::<Result<Vec<_>>>()?
4738                            .into_iter()
4739                            .all(|b| b);
4740                        if passes {
4741                            let new_cells = cells
4742                                .iter()
4743                                .map(|cell| {
4744                                    Ok((
4745                                        cell.column_id,
4746                                        eval_trigger_value(&cell.value, event, Some(&image))?,
4747                                    ))
4748                                })
4749                                .collect::<Result<Vec<_>>>()?;
4750                            let mut merged = row.columns.clone();
4751                            for (column_id, value) in new_cells {
4752                                merged.insert(column_id, value);
4753                            }
4754                            to_update.push((table_id, row.row_id, merged));
4755                        }
4756                    }
4757                    for (table_id, row_id, merged) in to_update {
4758                        out.added.push((
4759                            table_id,
4760                            crate::txn::Staged::Update(row_id, merged.into_iter().collect()),
4761                        ));
4762                        out.added_stacks.push(trigger_stack.to_vec());
4763                    }
4764                }
4765                TriggerStep::Raise { action, message } => match action {
4766                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
4767                    TriggerRaiseAction::Abort
4768                    | TriggerRaiseAction::Fail
4769                    | TriggerRaiseAction::Rollback => {
4770                        let message = eval_trigger_value(message, event, selected)?;
4771                        return Err(MongrelError::Conflict(format!(
4772                            "trigger {:?} raised: {}; trigger stack: {}",
4773                            trigger.name,
4774                            trigger_message(message),
4775                            Self::format_trigger_stack(trigger_stack)
4776                        )));
4777                    }
4778                },
4779            }
4780        }
4781        Ok(TriggerProgramOutcome::Continue)
4782    }
4783
4784    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
4785        let Some(stacks) = stacks else {
4786            return Vec::new();
4787        };
4788        let mut out = Vec::new();
4789        for idx in indices {
4790            let Some(stack) = stacks.get(*idx) else {
4791                continue;
4792            };
4793            for name in stack {
4794                if !out.iter().any(|existing| existing == name) {
4795                    out.push(name.clone());
4796                }
4797            }
4798        }
4799        out
4800    }
4801
4802    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
4803        let mut out = stack.to_vec();
4804        out.push(trigger_name.to_string());
4805        out
4806    }
4807
4808    fn format_trigger_stack(stack: &[String]) -> String {
4809        if stack.is_empty() {
4810            "<root>".into()
4811        } else {
4812            stack.join(" -> ")
4813        }
4814    }
4815
4816    /// Authoritatively validate every declared constraint on the staged write
4817    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
4818    /// SET NULL actions into explicit child ops. Called from
4819    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
4820    /// violation as an `Err`, aborting the commit atomically. This is the
4821    /// server-side authority point: concurrent remote writers that each pass
4822    /// their own client-side checks still cannot both commit a violating batch.
4823    ///
4824    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
4825    /// intra-transaction dedup; concurrent-txn races are additionally caught by
4826    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
4827    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
4828    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
4829    /// RESTRICT-only (cascade-truncate is unsupported).
4830    fn validate_constraints(
4831        &self,
4832        staging: &mut Vec<(u64, crate::txn::Staged)>,
4833        read_epoch: Epoch,
4834    ) -> Result<()> {
4835        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
4836        use crate::memtable::Row;
4837        use crate::txn::Staged;
4838        use std::collections::HashSet;
4839
4840        let snapshot = Snapshot::at(read_epoch);
4841        let cat = self.catalog.read();
4842
4843        // Collect live (id, name, constraints-bearing?) for staged tables.
4844        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
4845            .tables
4846            .iter()
4847            .filter(|e| matches!(e.state, TableState::Live))
4848            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
4849            .collect();
4850
4851        // Fast path: bail if no live table declares any constraints at all.
4852        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
4853        if !any_constraints {
4854            return Ok(());
4855        }
4856
4857        // Lazily-loaded visible rows per table, shared across checks.
4858        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
4859        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
4860            if let Some(r) = rows_cache.get(&table_id) {
4861                return Ok(r.clone());
4862            }
4863            let handle = self.table_by_id(table_id)?;
4864            let rows = handle.lock().visible_rows(snapshot)?;
4865            rows_cache.insert(table_id, rows.clone());
4866            Ok(rows)
4867        };
4868
4869        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
4870        // carry an explicit old RowId + full new image. This makes action choice
4871        // reliable even when the referenced key itself changes; a delete+put
4872        // heuristic cannot distinguish that from unrelated operations.
4873        let mut processed_updates = HashSet::new();
4874        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
4875        loop {
4876            let updates: Vec<PendingUpdate> = staging
4877                .iter()
4878                .enumerate()
4879                .filter_map(|(index, (table_id, op))| match op {
4880                    Staged::Update(row_id, cells) if !processed_updates.contains(&index) => {
4881                        Some((index, *table_id, *row_id, cells.clone()))
4882                    }
4883                    _ => None,
4884                })
4885                .collect();
4886            if updates.is_empty() {
4887                break;
4888            }
4889            let mut new_ops = Vec::new();
4890            for (index, table_id, row_id, new_cells) in updates {
4891                processed_updates.insert(index);
4892                let Some(tname) = live
4893                    .iter()
4894                    .find(|(id, _, _)| *id == table_id)
4895                    .map(|(_, name, _)| *name)
4896                else {
4897                    continue;
4898                };
4899                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
4900                    continue;
4901                };
4902                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
4903                for (child_id, _child_name, child_schema) in &live {
4904                    for fk in &child_schema.constraints.foreign_keys {
4905                        if fk.ref_table != tname {
4906                            continue;
4907                        }
4908                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
4909                        else {
4910                            continue;
4911                        };
4912                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
4913                            == Some(old_key.as_slice())
4914                        {
4915                            continue;
4916                        }
4917                        if fk.on_update == FkAction::Restrict {
4918                            continue;
4919                        }
4920                        let child_rows = load_rows(*child_id)?;
4921                        for child in child_rows {
4922                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
4923                                != Some(old_key.as_slice())
4924                            {
4925                                continue;
4926                            }
4927                            if staging.iter().any(|(id, op)| {
4928                                *id == *child_id
4929                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
4930                            }) {
4931                                continue;
4932                            }
4933                            let mut cells: Vec<(u16, Value)> = child
4934                                .columns
4935                                .iter()
4936                                .map(|(column_id, value)| (*column_id, value.clone()))
4937                                .collect();
4938                            for (child_column, parent_column) in
4939                                fk.columns.iter().zip(&fk.ref_columns)
4940                            {
4941                                cells.retain(|(column_id, _)| column_id != child_column);
4942                                let value = match fk.on_update {
4943                                    FkAction::Cascade => {
4944                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
4945                                    }
4946                                    FkAction::SetNull => Value::Null,
4947                                    FkAction::Restrict => unreachable!(),
4948                                };
4949                                cells.push((*child_column, value));
4950                            }
4951                            cells.sort_by_key(|(column_id, _)| *column_id);
4952                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
4953                                *id == *child_id
4954                                    && matches!(op, Staged::Update(id, _) if *id == child.row_id)
4955                            }) {
4956                                if let Staged::Update(_, existing) = &mut staging[existing_index].1
4957                                {
4958                                    if *existing != cells {
4959                                        *existing = cells;
4960                                        processed_updates.remove(&existing_index);
4961                                    }
4962                                }
4963                            } else {
4964                                new_ops.push((*child_id, Staged::Update(child.row_id, cells)));
4965                            }
4966                        }
4967                    }
4968                }
4969            }
4970            staging.extend(new_ops);
4971        }
4972
4973        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
4974        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
4975        // enforced as a violation in Phase B. `cascaded` records every delete
4976        // we have already expanded so a self-referential CASCADE FK cannot loop.
4977        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
4978        loop {
4979            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
4980            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
4981                .iter()
4982                .filter_map(|(t, op)| match op {
4983                    Staged::Delete(rid) => Some((*t, *rid)),
4984                    _ => None,
4985                })
4986                .collect();
4987            for (table_id, rid) in deletes {
4988                if !cascaded.insert((table_id, rid.0)) {
4989                    continue;
4990                }
4991                let Some(tname) = live
4992                    .iter()
4993                    .find(|(t, _, _)| *t == table_id)
4994                    .map(|(_, n, _)| *n)
4995                else {
4996                    continue;
4997                };
4998                let parent_handle = self.table_by_id(table_id)?;
4999                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
5000                    continue;
5001                };
5002                for (child_id, _child_name, child_schema) in &live {
5003                    for fk in &child_schema.constraints.foreign_keys {
5004                        if fk.ref_table != tname {
5005                            continue;
5006                        }
5007                        let Some(parent_key) =
5008                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
5009                        else {
5010                            continue;
5011                        };
5012                        // Suppress ON DELETE cascade/set-null when this "delete"
5013                        // is actually half of an UPDATE encoded as Delete(old)+
5014                        // Put(new): if a staged Put in the SAME table still
5015                        // provides the referenced parent key, the parent still
5016                        // exists (its non-key columns changed) and the children
5017                        // must be left alone. A genuine delete, or an update
5018                        // that CHANGES the referenced key, has no preserving Put
5019                        // → cascade fires as before.
5020                        let key_preserved = staging.iter().any(|(t, op)| {
5021                            if *t != table_id {
5022                                return false;
5023                            }
5024                            let Staged::Put(cells) = op else {
5025                                return false;
5026                            };
5027                            let map: HashMap<u16, crate::memtable::Value> =
5028                                cells.iter().cloned().collect();
5029                            encode_composite_key(&fk.ref_columns, &map).as_deref()
5030                                == Some(parent_key.as_slice())
5031                        });
5032                        if key_preserved {
5033                            continue;
5034                        }
5035                        match fk.on_delete {
5036                            FkAction::Restrict => continue,
5037                            FkAction::Cascade => {
5038                                let child_rows = load_rows(*child_id)?;
5039                                for cr in &child_rows {
5040                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
5041                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
5042                                            == Some(parent_key.as_slice())
5043                                    {
5044                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
5045                                    }
5046                                }
5047                            }
5048                            FkAction::SetNull => {
5049                                let child_rows = load_rows(*child_id)?;
5050                                for cr in &child_rows {
5051                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
5052                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
5053                                            == Some(parent_key.as_slice())
5054                                    {
5055                                        // Re-emit the child row with the FK
5056                                        // columns set to NULL (delete + put).
5057                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
5058                                            .columns
5059                                            .iter()
5060                                            .map(|(k, v)| (*k, v.clone()))
5061                                            .collect();
5062                                        for cid in &fk.columns {
5063                                            cells.retain(|(k, _)| k != cid);
5064                                            cells.push((*cid, crate::memtable::Value::Null));
5065                                        }
5066                                        new_ops.push((*child_id, Staged::Update(cr.row_id, cells)));
5067                                    }
5068                                }
5069                            }
5070                        }
5071                    }
5072                }
5073            }
5074            if new_ops.is_empty() {
5075                break;
5076            }
5077            staging.extend(new_ops);
5078        }
5079
5080        // Rows staged for deletion in THIS transaction (now including cascaded
5081        // deletes). Used to exclude the old version of an updated row from
5082        // unique-existence scans.
5083        let staged_deletes: HashSet<(u64, u64)> = staging
5084            .iter()
5085            .filter_map(|(t, op)| match op {
5086                Staged::Delete(rid) | Staged::Update(rid, _) => Some((*t, rid.0)),
5087                _ => None,
5088            })
5089            .collect();
5090
5091        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
5092        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
5093
5094        // ── Phase B: validate the fully-expanded staging set.
5095        for (table_id, op) in staging.iter() {
5096            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
5097            else {
5098                continue;
5099            };
5100            let cells_map: HashMap<u16, crate::memtable::Value>;
5101            match op {
5102                Staged::Put(cells) | Staged::Update(_, cells) => {
5103                    cells_map = cells.iter().cloned().collect();
5104
5105                    // CHECK constraints.
5106                    if !schema.constraints.checks.is_empty() {
5107                        validate_checks(&schema.constraints.checks, &cells_map)?;
5108                    }
5109
5110                    // UNIQUE (non-PK) constraints.
5111                    for uc in &schema.constraints.uniques {
5112                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
5113                            continue; // NULL in a constrained column → skip (SQL).
5114                        };
5115                        let marker = (*table_id, uc.id, key.clone());
5116                        if !seen_unique.insert(marker) {
5117                            return Err(MongrelError::Conflict(format!(
5118                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
5119                                uc.name
5120                            )));
5121                        }
5122                        let rows = load_rows(*table_id)?;
5123                        for r in &rows {
5124                            // Skip rows this same transaction is deleting (the
5125                            // old version of an updated/cascade-deleted row).
5126                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
5127                                continue;
5128                            }
5129                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
5130                                if theirs == key {
5131                                    return Err(MongrelError::Conflict(format!(
5132                                        "UNIQUE constraint '{}' on table '{tname}' violated",
5133                                        uc.name
5134                                    )));
5135                                }
5136                            }
5137                        }
5138                    }
5139
5140                    // FK insert-side: parent must exist.
5141                    for fk in &schema.constraints.foreign_keys {
5142                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
5143                            continue; // NULL FK component → not checked (SQL).
5144                        };
5145                        let Some(parent_id) = cat
5146                            .tables
5147                            .iter()
5148                            .find(|t| t.name == fk.ref_table)
5149                            .map(|t| t.table_id)
5150                        else {
5151                            return Err(MongrelError::InvalidArgument(format!(
5152                                "FOREIGN KEY '{}' references unknown table '{}'",
5153                                fk.name, fk.ref_table
5154                            )));
5155                        };
5156                        let parent_rows = load_rows(parent_id)?;
5157                        let mut found = false;
5158                        for r in &parent_rows {
5159                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
5160                                continue;
5161                            }
5162                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
5163                                if pkey == child_key {
5164                                    found = true;
5165                                    break;
5166                                }
5167                            }
5168                        }
5169                        // Final-write-set FK validation: a parent inserted in
5170                        // THIS transaction also satisfies the FK. This enables
5171                        // atomic parent+child batches and cyclical/mutual FK
5172                        // inserts within a single transaction — the child sees
5173                        // the staged parent put even though it is not committed
5174                        // yet.
5175                        if !found {
5176                            for (st_table, st_op) in staging.iter() {
5177                                if *st_table != parent_id {
5178                                    continue;
5179                                }
5180                                if let Staged::Put(pcells) | Staged::Update(_, pcells) = st_op {
5181                                    let pmap: HashMap<u16, crate::memtable::Value> =
5182                                        pcells.iter().cloned().collect();
5183                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
5184                                    {
5185                                        if pkey == child_key {
5186                                            found = true;
5187                                            break;
5188                                        }
5189                                    }
5190                                }
5191                            }
5192                        }
5193                        if !found {
5194                            return Err(MongrelError::Conflict(format!(
5195                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
5196                                fk.name, fk.ref_table
5197                            )));
5198                        }
5199                    }
5200
5201                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
5202                    // expanded in Phase A; here the final child write set is
5203                    // known, so a child explicitly moved/deleted by this same
5204                    // transaction does not cause a false violation.
5205                    if let Staged::Update(row_id, _) = op {
5206                        let parent_handle = self.table_by_id(*table_id)?;
5207                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
5208                            continue;
5209                        };
5210                        for (child_id, child_name, child_schema) in &live {
5211                            for fk in &child_schema.constraints.foreign_keys {
5212                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
5213                                    continue;
5214                                }
5215                                let Some(old_key) =
5216                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
5217                                else {
5218                                    continue;
5219                                };
5220                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
5221                                    == Some(old_key.as_slice())
5222                                {
5223                                    continue;
5224                                }
5225                                for child in load_rows(*child_id)? {
5226                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
5227                                        != Some(old_key.as_slice())
5228                                    {
5229                                        continue;
5230                                    }
5231                                    let replacement = staging.iter().find_map(|(id, op)| {
5232                                        if *id != *child_id {
5233                                            return None;
5234                                        }
5235                                        match op {
5236                                            Staged::Delete(id) if *id == child.row_id => Some(None),
5237                                            Staged::Update(id, cells) if *id == child.row_id => {
5238                                                let map: HashMap<u16, Value> =
5239                                                    cells.iter().cloned().collect();
5240                                                Some(encode_composite_key(&fk.columns, &map))
5241                                            }
5242                                            _ => None,
5243                                        }
5244                                    });
5245                                    if replacement.is_some_and(|key| {
5246                                        key.as_deref() != Some(old_key.as_slice())
5247                                    }) {
5248                                        continue;
5249                                    }
5250                                    return Err(MongrelError::Conflict(format!(
5251                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
5252                                        fk.name
5253                                    )));
5254                                }
5255                            }
5256                        }
5257                    }
5258                }
5259                Staged::Delete(rid) => {
5260                    // FK ON DELETE RESTRICT: a child row (whose FK action is
5261                    // RESTRICT) referencing this parent blocks the delete.
5262                    // CASCADE/SET NULL children were expanded in Phase A.
5263                    let parent_handle = self.table_by_id(*table_id)?;
5264                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
5265                        continue;
5266                    };
5267                    for (child_id, child_name, child_schema) in &live {
5268                        for fk in &child_schema.constraints.foreign_keys {
5269                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
5270                                continue;
5271                            }
5272                            let Some(parent_key) =
5273                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
5274                            else {
5275                                continue;
5276                            };
5277                            let child_rows = load_rows(*child_id)?;
5278                            for r in &child_rows {
5279                                // A child already being deleted by this txn
5280                                // (cascade/inline) is not a restrict violation.
5281                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
5282                                    continue;
5283                                }
5284                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
5285                                    if ck == parent_key {
5286                                        return Err(MongrelError::Conflict(format!(
5287                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
5288                                            fk.name
5289                                        )));
5290                                    }
5291                                }
5292                            }
5293                        }
5294                    }
5295                }
5296                Staged::Truncate => {
5297                    // Truncate is RESTRICT-only: reject if any child references
5298                    // this table (any FK action), since cascade-truncate is
5299                    // unsupported.
5300                    for (child_id, child_name, child_schema) in &live {
5301                        for fk in &child_schema.constraints.foreign_keys {
5302                            if fk.ref_table != tname {
5303                                continue;
5304                            }
5305                            let child_rows = load_rows(*child_id)?;
5306                            if child_rows
5307                                .iter()
5308                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
5309                            {
5310                                return Err(MongrelError::Conflict(format!(
5311                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
5312                                    fk.name
5313                                )));
5314                            }
5315                        }
5316                    }
5317                }
5318            }
5319        }
5320        Ok(())
5321    }
5322
5323    fn validate_security_writes(
5324        &self,
5325        staging: &[(u64, crate::txn::Staged)],
5326        read_epoch: Epoch,
5327        explicit_principal: Option<&crate::auth::Principal>,
5328    ) -> Result<()> {
5329        use crate::security::PolicyCommand;
5330        use crate::txn::Staged;
5331
5332        let catalog = self.catalog.read();
5333        if catalog.security.rls_tables.is_empty() {
5334            return Ok(());
5335        }
5336        let security = catalog.security.clone();
5337        let table_names = catalog
5338            .tables
5339            .iter()
5340            .filter(|entry| matches!(entry.state, TableState::Live))
5341            .map(|entry| (entry.table_id, entry.name.clone()))
5342            .collect::<HashMap<_, _>>();
5343        drop(catalog);
5344        if !staging.iter().any(|(table_id, _)| {
5345            table_names
5346                .get(table_id)
5347                .is_some_and(|table| security.rls_enabled(table))
5348        }) {
5349            return Ok(());
5350        }
5351        let cached = self.principal.read().clone();
5352        let principal = explicit_principal
5353            .or(cached.as_ref())
5354            .ok_or(MongrelError::AuthRequired)?;
5355
5356        for (table_id, operation) in staging {
5357            let Some(table) = table_names.get(table_id) else {
5358                continue;
5359            };
5360            if !security.rls_enabled(table) || principal.is_admin {
5361                continue;
5362            }
5363            let denied = |command| MongrelError::PermissionDenied {
5364                required: match command {
5365                    PolicyCommand::Insert => crate::auth::Permission::Insert {
5366                        table: table.clone(),
5367                    },
5368                    PolicyCommand::Update => crate::auth::Permission::Update {
5369                        table: table.clone(),
5370                    },
5371                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
5372                        crate::auth::Permission::Delete {
5373                            table: table.clone(),
5374                        }
5375                    }
5376                },
5377                principal: principal.username.clone(),
5378            };
5379            match operation {
5380                Staged::Put(cells) => {
5381                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
5382                    row.columns.extend(cells.iter().cloned());
5383                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
5384                        return Err(denied(PolicyCommand::Insert));
5385                    }
5386                }
5387                Staged::Update(row_id, cells) => {
5388                    let old = self
5389                        .table_by_id(*table_id)?
5390                        .lock()
5391                        .get(*row_id, Snapshot::at(read_epoch))
5392                        .ok_or_else(|| {
5393                            MongrelError::NotFound(format!("row {} not found", row_id.0))
5394                        })?;
5395                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
5396                        return Err(denied(PolicyCommand::Update));
5397                    }
5398                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
5399                    new.columns.extend(cells.iter().cloned());
5400                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
5401                        return Err(denied(PolicyCommand::Update));
5402                    }
5403                }
5404                Staged::Delete(row_id) => {
5405                    let old = self
5406                        .table_by_id(*table_id)?
5407                        .lock()
5408                        .get(*row_id, Snapshot::at(read_epoch))
5409                        .ok_or_else(|| {
5410                            MongrelError::NotFound(format!("row {} not found", row_id.0))
5411                        })?;
5412                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
5413                        return Err(denied(PolicyCommand::Delete));
5414                    }
5415                }
5416                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
5417            }
5418        }
5419        Ok(())
5420    }
5421
5422    /// Seal a transaction (spec §9.3):
5423    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
5424    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
5425    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
5426    ///    group-sync, record conflict keys.
5427    /// 3. Publish — apply to tables, advance visible in-order.
5428    #[allow(clippy::too_many_arguments)]
5429    pub(crate) fn commit_transaction_with_external_states(
5430        &self,
5431        txn_id: u64,
5432        read_epoch: Epoch,
5433        mut staging: Vec<(u64, crate::txn::Staged)>,
5434        external_states: Vec<(String, Vec<u8>)>,
5435        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
5436        security_principal: Option<crate::auth::Principal>,
5437        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
5438    ) -> Result<Epoch> {
5439        use crate::memtable::Row;
5440        use crate::txn::{Staged, StagedOp, WriteKey};
5441        use crate::wal::Op;
5442        use std::collections::hash_map::DefaultHasher;
5443        use std::hash::{Hash, Hasher};
5444        use std::sync::atomic::Ordering;
5445
5446        if self.read_only {
5447            return Err(MongrelError::ReadOnlyReplica);
5448        }
5449        let _replication_guard = self.replication_barrier.read();
5450        if self.poisoned.load(Ordering::Relaxed) {
5451            return Err(MongrelError::Other(
5452                "database poisoned by fsync error".into(),
5453            ));
5454        }
5455        let mut external_states = dedup_external_states(external_states);
5456        if !external_states.is_empty() {
5457            let cat = self.catalog.read();
5458            for (name, _) in &external_states {
5459                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
5460                    return Err(MongrelError::NotFound(format!(
5461                        "external table {name:?} not found"
5462                    )));
5463                }
5464            }
5465        }
5466        let prepared_materialized_views = {
5467            let mut deduplicated = HashMap::new();
5468            for definition in materialized_view_updates {
5469                if definition.name.is_empty() || definition.query.trim().is_empty() {
5470                    return Err(MongrelError::InvalidArgument(
5471                        "materialized view name and query must not be empty".into(),
5472                    ));
5473                }
5474                deduplicated.insert(definition.name.clone(), definition);
5475            }
5476            let catalog = self.catalog.read();
5477            let mut prepared = Vec::with_capacity(deduplicated.len());
5478            for definition in deduplicated.into_values() {
5479                let table_id = catalog
5480                    .live(&definition.name)
5481                    .ok_or_else(|| {
5482                        MongrelError::NotFound(format!(
5483                            "materialized view table {:?} not found",
5484                            definition.name
5485                        ))
5486                    })?
5487                    .table_id;
5488                prepared.push((table_id, definition));
5489            }
5490            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
5491            prepared
5492        };
5493
5494        // ── 1. Prepare: fill generated values, expand triggers, validate, then
5495        // derive write keys from the final atomic write set.
5496        self.fill_auto_increment_for_staging(&mut staging)?;
5497        self.expand_table_triggers(
5498            &mut staging,
5499            read_epoch,
5500            external_trigger_bridge,
5501            &mut external_states,
5502        )?;
5503        self.fill_auto_increment_for_staging(&mut staging)?;
5504        external_states = dedup_external_states(external_states);
5505
5506        // Validate declarative constraints (unique / FK / check) under the read
5507        // snapshot, outside the WAL mutex. Trigger-produced writes are included
5508        // here, so the batch either satisfies every declared constraint or is
5509        // rejected atomically.
5510        self.validate_constraints(&mut staging, read_epoch)?;
5511        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref())?;
5512        let mut normalized = Vec::with_capacity(staging.len() * 2);
5513        for (table_id, op) in staging {
5514            match op {
5515                crate::txn::Staged::Update(row_id, cells) => {
5516                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
5517                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
5518                }
5519                op => normalized.push((table_id, op)),
5520            }
5521        }
5522        staging = normalized;
5523        let has_changes = !staging.is_empty()
5524            || !external_states.is_empty()
5525            || !prepared_materialized_views.is_empty();
5526        let truncated_tables: HashSet<u64> = staging
5527            .iter()
5528            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
5529            .collect();
5530
5531        let write_keys = {
5532            let cat = self.catalog.read();
5533            let mut keys: Vec<WriteKey> = Vec::new();
5534            for (table_id, staged) in &staging {
5535                match staged {
5536                    Staged::Put(cells) => {
5537                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
5538                            for col in &entry.schema.columns {
5539                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
5540                                    if let Some((_, val)) =
5541                                        cells.iter().find(|(id, _)| *id == col.id)
5542                                    {
5543                                        let mut h = DefaultHasher::new();
5544                                        val.encode_key().hash(&mut h);
5545                                        keys.push(WriteKey::Unique {
5546                                            table_id: *table_id,
5547                                            index_id: 0,
5548                                            key_hash: h.finish(),
5549                                        });
5550                                    }
5551                                }
5552                            }
5553                            // Declared non-PK unique constraints register a
5554                            // `WriteKey::Unique` (namespace-separated from the
5555                            // PK's index_id==0 by setting the high bit) so two
5556                            // concurrent transactions inserting the same key
5557                            // cannot both commit. Rows with any NULL constrained
5558                            // column are skipped (SQL semantics).
5559                            for uc in &entry.schema.constraints.uniques {
5560                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
5561                                    &uc.columns,
5562                                    &cells.iter().cloned().collect(),
5563                                ) {
5564                                    let mut h = DefaultHasher::new();
5565                                    key_bytes.hash(&mut h);
5566                                    keys.push(WriteKey::Unique {
5567                                        table_id: *table_id,
5568                                        index_id: uc.id | 0x8000,
5569                                        key_hash: h.finish(),
5570                                    });
5571                                }
5572                            }
5573                        }
5574                    }
5575                    Staged::Delete(rid) => keys.push(WriteKey::Row {
5576                        table_id: *table_id,
5577                        row_id: rid.0,
5578                    }),
5579                    Staged::Truncate => keys.push(WriteKey::Table {
5580                        table_id: *table_id,
5581                    }),
5582                    Staged::Update(_, _) => unreachable!("updates normalized before prepare"),
5583                }
5584            }
5585            for (name, _) in &external_states {
5586                let mut h = DefaultHasher::new();
5587                name.hash(&mut h);
5588                keys.push(WriteKey::Unique {
5589                    table_id: EXTERNAL_TABLE_ID,
5590                    index_id: 0,
5591                    key_hash: h.finish(),
5592                });
5593            }
5594            keys
5595        };
5596
5597        // Opportunistic pruning.
5598        let min_active = self.active_txns.min_read_epoch();
5599        if min_active < u64::MAX {
5600            self.conflicts.prune_below(Epoch(min_active));
5601        }
5602
5603        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
5604        // §8.5, review fix #17). Snapshot the conflict-index version so the
5605        // sequencer only re-checks if new commits arrived in the interim.
5606        if self.conflicts.conflicts(&write_keys, read_epoch) {
5607            return Err(MongrelError::Conflict(
5608                "write-write conflict (pre-validate, first-committer-wins)".into(),
5609            ));
5610        }
5611        let pre_validate_version = self.conflicts.version();
5612
5613        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
5614        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
5615        // streamed as Put records; they are linked at publish time.
5616        let mut spilled: Vec<SpilledRun> = Vec::new();
5617        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
5618        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
5619        // as the spill runs are live (registered on first spill, dropped at the
5620        // end of this function on commit/abort/error).
5621        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
5622        {
5623            let mut table_bytes: HashMap<u64, usize> = HashMap::new();
5624            for (table_id, staged) in &staging {
5625                if let Staged::Put(cells) = staged {
5626                    *table_bytes.entry(*table_id).or_default() += cells.len() * 16;
5627                }
5628            }
5629            let tables = self.tables.read();
5630            for (&table_id, &bytes) in &table_bytes {
5631                if bytes as u64
5632                    <= self
5633                        .spill_threshold
5634                        .load(std::sync::atomic::Ordering::Relaxed)
5635                {
5636                    continue;
5637                }
5638                let Some(handle) = tables.get(&table_id) else {
5639                    continue;
5640                };
5641                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
5642                let mut t = handle.lock();
5643                let tdir = t.table_dir().to_path_buf();
5644                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
5645                std::fs::create_dir_all(&txn_dir)?;
5646                let run_id = t.alloc_run_id() as u128;
5647                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
5648
5649                let mut rows: Vec<Row> = Vec::new();
5650                for (tid, staged) in &staging {
5651                    if *tid != table_id {
5652                        continue;
5653                    }
5654                    if let Staged::Put(cells) = staged {
5655                        t.validate_cells_not_null(cells)?;
5656                        let row_id = t.alloc_row_id();
5657                        let mut row = Row::new(row_id, Epoch(0));
5658                        for (c, v) in cells {
5659                            row.columns.insert(*c, v.clone());
5660                        }
5661                        rows.push(row);
5662                    }
5663                }
5664                let schema = t.schema_ref().clone();
5665                let kek = t.kek_ref().cloned();
5666                let specs = t.indexable_column_specs();
5667                drop(t);
5668
5669                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
5670                    .uniform_epoch(true);
5671                if let Some(ref kek) = kek {
5672                    writer = writer.with_encryption(kek.as_ref(), specs);
5673                }
5674                let header = writer.write(&pending_path, &rows)?;
5675                let row_count = header.row_count;
5676                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
5677                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
5678
5679                spilled.push(SpilledRun {
5680                    table_id,
5681                    run_id,
5682                    pending_path,
5683                    rows,
5684                    row_count,
5685                    min_rid,
5686                    max_rid,
5687                });
5688                spilled_tables.insert(table_id);
5689            }
5690        }
5691
5692        // Test seam: let a test race `gc()` against this in-flight spill.
5693        if spill_guard.is_some() {
5694            if let Some(hook) = self.spill_hook.lock().as_ref() {
5695                hook();
5696            }
5697        }
5698
5699        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
5700        // Allocating row ids + building the rows here (lock order: table handle →
5701        // nothing) means the sequencer never locks a table handle while holding
5702        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
5703        // the table handle THEN the shared WAL; if the sequencer did the reverse
5704        // (WAL then handle) the two paths would deadlock (review fix: B1).
5705        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
5706        // Row ids are allocated here, before the sequencer's delta conflict
5707        // re-check, so a losing txn leaks the ids it reserved — harmless, the
5708        // u64 row-id space is monotonic and gaps are expected (spills do the same).
5709        let mut prebuilt: Vec<Option<Row>> = Vec::with_capacity(staging.len());
5710        let mut delete_images: Vec<Option<Row>> = Vec::with_capacity(staging.len());
5711        {
5712            let tables = self.tables.read();
5713            for (table_id, staged) in &staging {
5714                match staged {
5715                    Staged::Put(cells) if !spilled_tables.contains(table_id) => {
5716                        let handle = tables.get(table_id).ok_or_else(|| {
5717                            MongrelError::NotFound(format!("table {table_id} not mounted"))
5718                        })?;
5719                        let mut t = handle.lock();
5720                        t.validate_cells_not_null(cells)?;
5721                        let row_id = t.alloc_row_id();
5722                        drop(t);
5723                        let mut row = Row::new(row_id, Epoch(0));
5724                        for (c, v) in cells {
5725                            row.columns.insert(*c, v.clone());
5726                        }
5727                        prebuilt.push(Some(row));
5728                        delete_images.push(None);
5729                    }
5730                    Staged::Delete(row_id) => {
5731                        let before = tables.get(table_id).and_then(|handle| {
5732                            handle.lock().get(*row_id, Snapshot::at(read_epoch))
5733                        });
5734                        prebuilt.push(None);
5735                        delete_images.push(before);
5736                    }
5737                    Staged::Put(_) | Staged::Truncate => {
5738                        prebuilt.push(None);
5739                        delete_images.push(None);
5740                    }
5741                    Staged::Update(_, _) => unreachable!("updates normalized before prepare"),
5742                }
5743            }
5744        }
5745
5746        let mut prepared_external = Vec::with_capacity(external_states.len());
5747        for (name, state) in &external_states {
5748            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
5749            prepared_external.push((name.clone(), state.clone(), pending));
5750        }
5751
5752        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
5753        let added_runs: Vec<crate::wal::AddedRun> = spilled
5754            .iter()
5755            .map(|s| crate::wal::AddedRun {
5756                table_id: s.table_id,
5757                run_id: s.run_id,
5758                row_count: s.row_count,
5759                level: 0,
5760                min_row_id: s.min_rid,
5761                max_row_id: s.max_rid,
5762                content_hash: [0u8; 32],
5763            })
5764            .collect();
5765        let (new_epoch, mut _epoch_guard, applies, committed_materialized_views, commit_seq) = {
5766            let mut wal = self.shared_wal.lock();
5767
5768            // Re-check only if the conflict index advanced since pre-validation
5769            // (bounded delta — spec §8.5, review fix #17). If the version is
5770            // unchanged, the pre-check result is still valid and the sequencer
5771            // does O(1) work regardless of write-set size.
5772            if self.conflicts.version() != pre_validate_version
5773                && self.conflicts.conflicts(&write_keys, read_epoch)
5774            {
5775                // Abort: this txn assigned no epoch yet, so drop the quarantined
5776                // spill runs we wrote during prepare instead of leaking them in
5777                // `_txn/` until the next GC/reopen sweep.
5778                drop(wal);
5779                for s in &spilled {
5780                    if let Some(parent) = s.pending_path.parent() {
5781                        let _ = std::fs::remove_dir_all(parent);
5782                    }
5783                }
5784                for (_, _, pending) in &prepared_external {
5785                    let _ = std::fs::remove_file(pending);
5786                }
5787                return Err(MongrelError::Conflict(
5788                    "write-write conflict (sequencer delta re-check)".into(),
5789                ));
5790            }
5791
5792            let new_epoch = self.epoch.bump_assigned();
5793            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), new_epoch);
5794            let mut applies: Vec<(u64, Vec<StagedOp>)> = Vec::new();
5795            let mut committed_materialized_views = Vec::new();
5796
5797            for (idx, (table_id, staged)) in staging.iter().enumerate() {
5798                // Skip puts for tables that were spilled — their data is in a
5799                // pending run, not in streamed Put records.
5800                if spilled_tables.contains(table_id) && matches!(staged, Staged::Put(_)) {
5801                    continue;
5802                }
5803                let mut ops = Vec::new();
5804                match staged {
5805                    Staged::Put(_) => {
5806                        // Stamp the pre-built row at the real assigned epoch.
5807                        let mut row = prebuilt[idx].take().expect("prebuilt put row");
5808                        row.committed_epoch = new_epoch;
5809                        let payload = bincode::serialize(&vec![row.clone()])
5810                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
5811                        wal.append(
5812                            txn_id,
5813                            *table_id,
5814                            Op::Put {
5815                                table_id: *table_id,
5816                                rows: payload,
5817                            },
5818                        )?;
5819                        ops.push(StagedOp::Put(row));
5820                    }
5821                    Staged::Delete(rid) => {
5822                        if let Some(before) = &delete_images[idx] {
5823                            wal.append(
5824                                txn_id,
5825                                *table_id,
5826                                Op::BeforeImage {
5827                                    table_id: *table_id,
5828                                    row_id: *rid,
5829                                    row: bincode::serialize(before).map_err(|error| {
5830                                        MongrelError::Other(format!(
5831                                            "before-image serialize: {error}"
5832                                        ))
5833                                    })?,
5834                                },
5835                            )?;
5836                        }
5837                        wal.append(
5838                            txn_id,
5839                            *table_id,
5840                            Op::Delete {
5841                                table_id: *table_id,
5842                                row_ids: vec![*rid],
5843                            },
5844                        )?;
5845                        ops.push(StagedOp::Delete(*rid));
5846                    }
5847                    Staged::Truncate => {
5848                        wal.append(
5849                            txn_id,
5850                            *table_id,
5851                            Op::TruncateTable {
5852                                table_id: *table_id,
5853                            },
5854                        )?;
5855                        ops.push(StagedOp::Truncate);
5856                    }
5857                    Staged::Update(_, _) => unreachable!("updates normalized before sequencer"),
5858                }
5859                applies.push((*table_id, ops));
5860            }
5861
5862            for (name, state, _) in &prepared_external {
5863                wal.append(
5864                    txn_id,
5865                    EXTERNAL_TABLE_ID,
5866                    Op::ExternalTableState {
5867                        name: name.clone(),
5868                        state: state.clone(),
5869                    },
5870                )?;
5871            }
5872
5873            for (table_id, definition) in &prepared_materialized_views {
5874                let mut definition = definition.clone();
5875                definition.last_refresh_epoch = new_epoch.0;
5876                wal.append(
5877                    txn_id,
5878                    *table_id,
5879                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
5880                        name: definition.name.clone(),
5881                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
5882                    }),
5883                )?;
5884                committed_materialized_views.push(definition);
5885            }
5886
5887            let commit_seq = wal.append_commit(txn_id, new_epoch, &added_runs)?;
5888
5889            // Record the conflict + assign the epoch under the WAL lock so commit
5890            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
5891            // moves out of this critical section to the group-commit coordinator
5892            // so concurrent committers share a single leader fsync.
5893            self.conflicts.record(&write_keys, new_epoch);
5894            (
5895                new_epoch,
5896                _epoch_guard,
5897                applies,
5898                committed_materialized_views,
5899                commit_seq,
5900            )
5901        };
5902
5903        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
5904        self.group
5905            .await_durable(&self.shared_wal, commit_seq)
5906            .inspect_err(|_| {
5907                self.poisoned.store(true, Ordering::Relaxed);
5908            })?;
5909
5910        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
5911        {
5912            let tables = self.tables.read();
5913            // Apply truncates/deletes before linking spilled replacement rows.
5914            // This makes TRUNCATE + INSERT a single atomic replace even when
5915            // the insert side exceeds the spill threshold.
5916            for (table_id, ops) in applies {
5917                if let Some(handle) = tables.get(&table_id) {
5918                    let mut t = handle.lock();
5919                    for op in ops {
5920                        match op {
5921                            StagedOp::Put(row) => t.apply_put_rows(vec![row])?,
5922                            StagedOp::Delete(rid) => t.apply_delete(rid, new_epoch),
5923                            StagedOp::Truncate => t.apply_truncate(new_epoch)?,
5924                        }
5925                    }
5926                    t.invalidate_pending_cache();
5927                    t.persist_manifest(new_epoch)?;
5928                }
5929            }
5930            for s in &spilled {
5931                if let Some(handle) = tables.get(&s.table_id) {
5932                    let mut t = handle.lock();
5933                    let dest = t.run_path(s.run_id as u64);
5934                    std::fs::rename(&s.pending_path, &dest)?;
5935                    if let Some(parent) = s.pending_path.parent() {
5936                        let _ = std::fs::remove_dir_all(parent);
5937                    }
5938                    t.link_run(crate::manifest::RunRef {
5939                        run_id: s.run_id,
5940                        level: 0,
5941                        epoch_created: new_epoch.0,
5942                        row_count: s.row_count,
5943                    });
5944                    t.apply_run_metadata(&s.rows)?;
5945                    if truncated_tables.contains(&s.table_id) {
5946                        // TRUNCATE + spilled puts fully describe this table at
5947                        // the commit epoch. Endorse the epoch so clean-reopen
5948                        // recovery does not replay the truncate over the
5949                        // already-linked replacement run.
5950                        t.set_flushed_epoch(new_epoch);
5951                    }
5952                    t.invalidate_pending_cache();
5953                    t.persist_manifest(new_epoch)?;
5954                }
5955            }
5956        }
5957        for (name, _, pending) in &prepared_external {
5958            publish_external_state_file(&self.root, name, pending)?;
5959        }
5960        if !committed_materialized_views.is_empty() {
5961            {
5962                let mut catalog = self.catalog.write();
5963                for definition in committed_materialized_views {
5964                    if let Some(existing) = catalog
5965                        .materialized_views
5966                        .iter_mut()
5967                        .find(|existing| existing.name == definition.name)
5968                    {
5969                        *existing = definition;
5970                    } else {
5971                        catalog.materialized_views.push(definition);
5972                    }
5973                }
5974                catalog.db_epoch = catalog.db_epoch.max(new_epoch.0);
5975            }
5976            catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
5977        }
5978
5979        self.epoch.publish_in_order(new_epoch);
5980        if has_changes {
5981            let _ = self.change_wake.send(());
5982        }
5983        _epoch_guard.disarm();
5984        Ok(new_epoch)
5985    }
5986
5987    /// Register a read snapshot at the current visible epoch and return it with
5988    /// a guard that retains it for GC until dropped.
5989    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
5990        let e = self.epoch.visible();
5991        let g = self.snapshots.register(e);
5992        (Snapshot::at(e), g)
5993    }
5994
5995    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
5996    /// retention.
5997    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
5998        let e = self.epoch.visible();
5999        let g = self.snapshots.register_owned(e);
6000        (Snapshot::at(e), g)
6001    }
6002
6003    /// Configure a rolling history window measured in prior commit epochs.
6004    /// The first enable starts at the current epoch because earlier versions
6005    /// may already have been compacted. Increasing the window likewise cannot
6006    /// recreate history that fell outside the previous guarantee.
6007    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
6008        let _guard = self.ddl_lock.lock();
6009        let current = self.epoch.visible();
6010        let (old_epochs, old_start) = self.snapshots.history_config();
6011        let earliest_already_guaranteed = if old_epochs == 0 {
6012            current
6013        } else {
6014            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
6015        };
6016        let start = if epochs == 0 {
6017            current
6018        } else {
6019            earliest_already_guaranteed
6020        };
6021        write_history_retention(&self.root, epochs, start)?;
6022        self.snapshots.configure_history(epochs, start);
6023        Ok(())
6024    }
6025
6026    pub fn history_retention_epochs(&self) -> u64 {
6027        self.snapshots.history_config().0
6028    }
6029
6030    pub fn earliest_retained_epoch(&self) -> Epoch {
6031        let current = self.epoch.visible();
6032        self.snapshots.history_floor(current).unwrap_or(current)
6033    }
6034
6035    /// Pin a guaranteed historical epoch for the lifetime of the returned
6036    /// guard. Rejects future epochs and epochs outside the configured window.
6037    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
6038        let current = self.epoch.visible();
6039        if epoch > current {
6040            return Err(MongrelError::InvalidArgument(format!(
6041                "epoch {} is in the future; current epoch is {}",
6042                epoch.0, current.0
6043            )));
6044        }
6045        let earliest = self.earliest_retained_epoch();
6046        if epoch < earliest {
6047            return Err(MongrelError::InvalidArgument(format!(
6048                "epoch {} is no longer retained; earliest available epoch is {}",
6049                epoch.0, earliest.0
6050            )));
6051        }
6052        let guard = self.snapshots.register_owned(epoch);
6053        Ok((Snapshot::at(epoch), guard))
6054    }
6055
6056    /// Names of all live tables.
6057    pub fn table_names(&self) -> Vec<String> {
6058        self.catalog
6059            .read()
6060            .tables
6061            .iter()
6062            .filter(|t| matches!(t.state, TableState::Live))
6063            .map(|t| t.name.clone())
6064            .collect()
6065    }
6066
6067    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
6068    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
6069    /// reaped on the next open. Call this as the last action before a
6070    /// short-lived process (CLI, one-shot script) exits. The daemon does not
6071    /// need this — its background auto-compactor handles run management.
6072    pub fn close(&self) -> Result<()> {
6073        for name in self.table_names() {
6074            if let Ok(handle) = self.table(&name) {
6075                if let Err(e) = handle.lock().close() {
6076                    eprintln!("[close] flush failed for {name}: {e}");
6077                }
6078            }
6079        }
6080        Ok(())
6081    }
6082
6083    /// Compact every mounted table: merge all sorted runs into one clean run
6084    /// so query cost stays flat (single-run fast path) instead of growing
6085    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
6086    /// rows to reclaim. Each table
6087    /// is locked individually for its own compaction; snapshot retention is
6088    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
6089    pub fn compact(&self) -> Result<(usize, usize)> {
6090        self.require(&crate::auth::Permission::Ddl)?;
6091        let mut compacted = 0;
6092        let mut skipped = 0;
6093        for name in self.table_names() {
6094            let Ok(handle) = self.table(&name) else {
6095                continue;
6096            };
6097            {
6098                let mut t = handle.lock();
6099                let before = t.run_count();
6100                if before < 2 && !t.should_compact() {
6101                    skipped += 1;
6102                    continue;
6103                }
6104                match t.compact() {
6105                    Ok(()) => {
6106                        let after = t.run_count();
6107                        compacted += 1;
6108                        eprintln!("[compact] {name}: {before} -> {after} runs");
6109                    }
6110                    Err(e) => {
6111                        eprintln!("[compact] {name}: compaction failed: {e}");
6112                        skipped += 1;
6113                    }
6114                }
6115            }
6116        }
6117        Ok((compacted, skipped))
6118    }
6119
6120    /// Compact a single table by name. Returns `Ok(true)` if it was
6121    /// compacted, `Ok(false)` if skipped (< 2 runs).
6122    pub fn compact_table(&self, name: &str) -> Result<bool> {
6123        self.require(&crate::auth::Permission::Ddl)?;
6124        let handle = self.table(name)?;
6125        let mut t = handle.lock();
6126        let before = t.run_count();
6127        if before < 2 {
6128            return Ok(false);
6129        }
6130        t.compact()?;
6131        Ok(t.run_count() < before)
6132    }
6133
6134    /// Look up a live table by name.
6135    pub fn table(&self, name: &str) -> Result<TableHandle> {
6136        let cat = self.catalog.read();
6137        let entry = cat
6138            .live(name)
6139            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
6140        let id = entry.table_id;
6141        drop(cat);
6142        self.tables
6143            .read()
6144            .get(&id)
6145            .cloned()
6146            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
6147    }
6148
6149    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
6150    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
6151    pub fn has_ttl_tables(&self) -> bool {
6152        self.tables
6153            .read()
6154            .values()
6155            .any(|table| table.lock().ttl().is_some())
6156    }
6157
6158    /// Resolve a live table id → mounted handle (used by the constraint
6159    /// validation pass and other id-qualified internal paths).
6160    fn table_by_id(&self, id: u64) -> Result<TableHandle> {
6161        self.tables
6162            .read()
6163            .get(&id)
6164            .cloned()
6165            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
6166    }
6167
6168    /// Create a new table. The DDL is first logged to the shared WAL
6169    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
6170    /// BEFORE the in-memory catalog and table map are mutated; the catalog
6171    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
6172    /// that sees a stale catalog still recovers the table by replaying the Ddl.
6173    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
6174        use crate::wal::DdlOp;
6175        use std::sync::atomic::Ordering;
6176
6177        self.require(&crate::auth::Permission::Ddl)?;
6178        if self.poisoned.load(Ordering::Relaxed) {
6179            return Err(MongrelError::Other(
6180                "database poisoned by fsync error".into(),
6181            ));
6182        }
6183
6184        let _g = self.ddl_lock.lock();
6185        {
6186            let cat = self.catalog.read();
6187            if cat.live(name).is_some() {
6188                return Err(MongrelError::InvalidArgument(format!(
6189                    "table {name:?} already exists"
6190                )));
6191            }
6192        }
6193
6194        // Allocate id + epoch + txn id under the commit lock so the DDL commit
6195        // is serialized with data commits (in-order publish).
6196        let commit_lock = Arc::clone(&self.commit_lock);
6197        let _c = commit_lock.lock();
6198        let table_id = {
6199            let mut cat = self.catalog.write();
6200            let id = cat.next_table_id;
6201            cat.next_table_id += 1;
6202            id
6203        };
6204        let epoch = self.epoch.bump_assigned();
6205        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6206        let txn_id = self.alloc_txn_id();
6207
6208        // Stamp the schema_id with the unique table_id so every table in the
6209        // database has a distinct schema_id (caller-provided values are
6210        // ignored to prevent collisions).
6211        let mut schema = schema;
6212        schema.schema_id = table_id;
6213        // Defense in depth: reject an invalid schema BEFORE any durable
6214        // side-effect. `Table::create_in` re-validates, but by then the DDL has
6215        // already been appended to the shared WAL; a failing create_in would
6216        // leave a dangling entry that `recover_ddl_from_wal` replays without
6217        // re-validating, corrupting the catalog on reopen. Validating here
6218        // keeps the WAL free of schemas that can never be opened.
6219        schema.validate_auto_increment()?;
6220        schema.validate_defaults()?;
6221        schema.validate_ai()?;
6222        for index in &schema.indexes {
6223            index.validate_options()?;
6224        }
6225        for constraint in &schema.constraints.checks {
6226            constraint.expr.validate()?;
6227        }
6228
6229        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
6230        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
6231        let schema_json = DdlOp::encode_schema(&schema)?;
6232        let commit_seq = {
6233            let mut wal = self.shared_wal.lock();
6234            wal.append(
6235                txn_id,
6236                table_id,
6237                crate::wal::Op::Ddl(DdlOp::CreateTable {
6238                    table_id,
6239                    name: name.to_string(),
6240                    schema_json,
6241                }),
6242            )?;
6243            wal.append_commit(txn_id, epoch, &[])?
6244        };
6245        self.group
6246            .await_durable(&self.shared_wal, commit_seq)
6247            .inspect_err(|_| {
6248                self.poisoned.store(true, Ordering::Relaxed);
6249            })?;
6250
6251        // 2. Create the on-disk table dir + manifest.
6252        let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
6253        std::fs::create_dir_all(&tdir)?;
6254        let ctx = SharedCtx {
6255            epoch: Arc::clone(&self.epoch),
6256            page_cache: Arc::clone(&self.page_cache),
6257            decoded_cache: Arc::clone(&self.decoded_cache),
6258            snapshots: Arc::clone(&self.snapshots),
6259            kek: self.kek.clone(),
6260            commit_lock: Arc::clone(&self.commit_lock),
6261            shared: Some(crate::engine::SharedWalCtx {
6262                wal: Arc::clone(&self.shared_wal),
6263                group: Arc::clone(&self.group),
6264                poisoned: Arc::clone(&self.poisoned),
6265                txn_ids: Arc::clone(&self.next_txn_id),
6266                change_wake: self.change_wake.clone(),
6267            }),
6268            table_name: Some(name.to_string()),
6269            auth: self.table_auth_checker(),
6270            read_only: self.read_only,
6271        };
6272        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
6273
6274        // 3. Mutate the in-memory catalog + mount the table, then rewrite the
6275        //    catalog checkpoint (lazy: outside the WAL critical section).
6276        {
6277            let mut cat = self.catalog.write();
6278            cat.tables.push(CatalogEntry {
6279                table_id,
6280                name: name.to_string(),
6281                schema,
6282                state: TableState::Live,
6283                created_epoch: epoch.0,
6284            });
6285        }
6286        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
6287        self.tables
6288            .write()
6289            .insert(table_id, Arc::new(Mutex::new(table)));
6290
6291        self.epoch.publish_in_order(epoch);
6292        _epoch_guard.disarm();
6293        Ok(table_id)
6294    }
6295
6296    /// Logically drop a table, logging the DDL through the shared WAL first.
6297    pub fn drop_table(&self, name: &str) -> Result<()> {
6298        use crate::wal::DdlOp;
6299        use std::sync::atomic::Ordering;
6300
6301        self.require(&crate::auth::Permission::Ddl)?;
6302        if self.poisoned.load(Ordering::Relaxed) {
6303            return Err(MongrelError::Other(
6304                "database poisoned by fsync error".into(),
6305            ));
6306        }
6307
6308        let _g = self.ddl_lock.lock();
6309        let table_id = {
6310            let cat = self.catalog.read();
6311            cat.live(name)
6312                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
6313                .table_id
6314        };
6315
6316        let commit_lock = Arc::clone(&self.commit_lock);
6317        let _c = commit_lock.lock();
6318        let epoch = self.epoch.bump_assigned();
6319        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6320        let txn_id = self.alloc_txn_id();
6321        let commit_seq = {
6322            let mut wal = self.shared_wal.lock();
6323            wal.append(
6324                txn_id,
6325                table_id,
6326                crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
6327            )?;
6328            wal.append_commit(txn_id, epoch, &[])?
6329        };
6330        self.group
6331            .await_durable(&self.shared_wal, commit_seq)
6332            .inspect_err(|_| {
6333                self.poisoned.store(true, Ordering::Relaxed);
6334            })?;
6335
6336        {
6337            let mut cat = self.catalog.write();
6338            let entry = cat
6339                .tables
6340                .iter_mut()
6341                .find(|t| t.table_id == table_id)
6342                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
6343            entry.state = TableState::Dropped { at_epoch: epoch.0 };
6344            cat.triggers.retain(|trigger| {
6345                !matches!(
6346                    &trigger.trigger.target,
6347                    TriggerTarget::Table(target) if target == name
6348                )
6349            });
6350            cat.materialized_views
6351                .retain(|definition| definition.name != name);
6352            cat.security.rls_tables.retain(|table| table != name);
6353            cat.security.policies.retain(|policy| policy.table != name);
6354            cat.security.masks.retain(|mask| mask.table != name);
6355            for role in &mut cat.roles {
6356                role.permissions
6357                    .retain(|permission| permission_table(permission) != Some(name));
6358            }
6359            cat.security_version = cat.security_version.wrapping_add(1);
6360        }
6361        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
6362        self.tables.write().remove(&table_id);
6363
6364        self.epoch.publish_in_order(epoch);
6365        _epoch_guard.disarm();
6366        Ok(())
6367    }
6368
6369    /// Rename a live table. `name` must exist and `new_name` must not collide
6370    /// with any live table; both checks run under `ddl_lock` so they are atomic
6371    /// with the rename and with concurrent `create_table` existence checks (no
6372    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
6373    /// side-effects. The rename is logged to the shared WAL as
6374    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
6375    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
6376    /// the in-memory object does not move — only the catalog name changes).
6377    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
6378        use crate::wal::DdlOp;
6379        use std::sync::atomic::Ordering;
6380
6381        self.require(&crate::auth::Permission::Ddl)?;
6382        if self.poisoned.load(Ordering::Relaxed) {
6383            return Err(MongrelError::Other(
6384                "database poisoned by fsync error".into(),
6385            ));
6386        }
6387
6388        // A no-op rename short-circuits before any locking, so it can never
6389        // trip the "target already exists" check (the source *is* that name).
6390        if name == new_name {
6391            return Ok(());
6392        }
6393        if new_name.is_empty() {
6394            return Err(MongrelError::InvalidArgument(
6395                "rename_table: new name must not be empty".into(),
6396            ));
6397        }
6398
6399        let _g = self.ddl_lock.lock();
6400        let table_id = {
6401            let cat = self.catalog.read();
6402            let src = cat
6403                .live(name)
6404                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
6405            // Target must be free. Checked under ddl_lock, which every other
6406            // DDL (create/rename/drop) also holds, so a concurrent operation
6407            // cannot claim `new_name` between this check and the catalog write.
6408            if cat.live(new_name).is_some() {
6409                return Err(MongrelError::InvalidArgument(format!(
6410                    "rename_table: a table named {new_name:?} already exists"
6411                )));
6412            }
6413            src.table_id
6414        };
6415
6416        let commit_lock = Arc::clone(&self.commit_lock);
6417        let _c = commit_lock.lock();
6418        let epoch = self.epoch.bump_assigned();
6419        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6420        let txn_id = self.alloc_txn_id();
6421        let commit_seq = {
6422            let mut wal = self.shared_wal.lock();
6423            wal.append(
6424                txn_id,
6425                table_id,
6426                crate::wal::Op::Ddl(DdlOp::RenameTable {
6427                    table_id,
6428                    new_name: new_name.to_string(),
6429                }),
6430            )?;
6431            wal.append_commit(txn_id, epoch, &[])?
6432        };
6433        self.group
6434            .await_durable(&self.shared_wal, commit_seq)
6435            .inspect_err(|_| {
6436                self.poisoned.store(true, Ordering::Relaxed);
6437            })?;
6438
6439        {
6440            let mut cat = self.catalog.write();
6441            let entry = cat
6442                .tables
6443                .iter_mut()
6444                .find(|t| t.table_id == table_id)
6445                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
6446            entry.name = new_name.to_string();
6447            for trigger in &mut cat.triggers {
6448                if matches!(
6449                    &trigger.trigger.target,
6450                    TriggerTarget::Table(target) if target == name
6451                ) {
6452                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
6453                }
6454            }
6455            if let Some(definition) = cat
6456                .materialized_views
6457                .iter_mut()
6458                .find(|definition| definition.name == name)
6459            {
6460                definition.name = new_name.to_string();
6461            }
6462            for table in &mut cat.security.rls_tables {
6463                if table == name {
6464                    *table = new_name.to_string();
6465                }
6466            }
6467            for policy in &mut cat.security.policies {
6468                if policy.table == name {
6469                    policy.table = new_name.to_string();
6470                }
6471            }
6472            for mask in &mut cat.security.masks {
6473                if mask.table == name {
6474                    mask.table = new_name.to_string();
6475                }
6476            }
6477            for role in &mut cat.roles {
6478                for permission in &mut role.permissions {
6479                    rename_permission_table(permission, name, new_name);
6480                }
6481            }
6482            cat.security_version = cat.security_version.wrapping_add(1);
6483        }
6484        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
6485        // The in-memory table object is keyed by table_id, not name, so it does
6486        // not move and live TableHandles remain valid.
6487        if let Some(table) = self.tables.read().get(&table_id) {
6488            table.lock().set_catalog_name(new_name.to_string());
6489        }
6490        self.epoch.publish_in_order(epoch);
6491        _epoch_guard.disarm();
6492        Ok(())
6493    }
6494
6495    pub fn alter_column(
6496        &self,
6497        table_name: &str,
6498        column_name: &str,
6499        change: AlterColumn,
6500    ) -> Result<ColumnDef> {
6501        use crate::wal::DdlOp;
6502        use std::sync::atomic::Ordering;
6503
6504        self.require(&crate::auth::Permission::Ddl)?;
6505        if self.poisoned.load(Ordering::Relaxed) {
6506            return Err(MongrelError::Other(
6507                "database poisoned by fsync error".into(),
6508            ));
6509        }
6510
6511        let _g = self.ddl_lock.lock();
6512        let table_id = {
6513            let cat = self.catalog.read();
6514            cat.live(table_name)
6515                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
6516                .table_id
6517        };
6518        let handle =
6519            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
6520                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
6521            })?;
6522
6523        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
6524        // declared default, backfill existing NULL/absent cells as one durable
6525        // transaction before logging the metadata change. A crash between the
6526        // two commits leaves a harmless nullable-but-filled column; retry is
6527        // idempotent because only remaining NULLs are touched.
6528        let backfill = {
6529            let table = handle.lock();
6530            let old = table
6531                .schema()
6532                .column(column_name)
6533                .cloned()
6534                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
6535            let next_flags = change.flags.unwrap_or(old.flags);
6536            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
6537                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
6538                && old.default_value.is_some()
6539            {
6540                let snapshot = Snapshot::at(self.epoch.visible());
6541                let mut updates = Vec::new();
6542                for row in table.visible_rows(snapshot)? {
6543                    if row
6544                        .columns
6545                        .get(&old.id)
6546                        .is_some_and(|value| !matches!(value, Value::Null))
6547                    {
6548                        continue;
6549                    }
6550                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
6551                    table.apply_defaults(&mut cells)?;
6552                    updates.push((table_id, crate::txn::Staged::Update(row.row_id, cells)));
6553                }
6554                updates
6555            } else {
6556                Vec::new()
6557            }
6558        };
6559        if !backfill.is_empty() {
6560            self.commit_transaction_with_external_states(
6561                self.alloc_txn_id(),
6562                self.epoch.visible(),
6563                backfill,
6564                Vec::new(),
6565                Vec::new(),
6566                None,
6567                None,
6568            )?;
6569        }
6570        let mut table = handle.lock();
6571        let column = table.prepare_alter_column(column_name, &change)?;
6572        let renamed_column = (column.name != column_name).then(|| column.name.clone());
6573        if table
6574            .schema()
6575            .columns
6576            .iter()
6577            .find(|c| c.id == column.id)
6578            .is_some_and(|c| c == &column)
6579        {
6580            return Ok(column);
6581        }
6582
6583        let commit_lock = Arc::clone(&self.commit_lock);
6584        let _c = commit_lock.lock();
6585        let epoch = self.epoch.bump_assigned();
6586        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6587        let txn_id = self.alloc_txn_id();
6588        let column_json = DdlOp::encode_column(&column)?;
6589        let commit_seq = {
6590            let mut wal = self.shared_wal.lock();
6591            wal.append(
6592                txn_id,
6593                table_id,
6594                crate::wal::Op::Ddl(DdlOp::AlterTable {
6595                    table_id,
6596                    column_json,
6597                }),
6598            )?;
6599            wal.append_commit(txn_id, epoch, &[])?
6600        };
6601        self.group
6602            .await_durable(&self.shared_wal, commit_seq)
6603            .inspect_err(|_| {
6604                self.poisoned.store(true, Ordering::Relaxed);
6605            })?;
6606
6607        table.apply_altered_column(column.clone())?;
6608        let schema = table.schema().clone();
6609        drop(table);
6610
6611        {
6612            let mut cat = self.catalog.write();
6613            let entry = cat
6614                .tables
6615                .iter_mut()
6616                .find(|t| t.table_id == table_id)
6617                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
6618            entry.schema = schema;
6619            if let Some(new_column_name) = renamed_column {
6620                for trigger in &mut cat.triggers {
6621                    if matches!(
6622                        &trigger.trigger.target,
6623                        TriggerTarget::Table(target) if target == table_name
6624                    ) {
6625                        trigger.trigger = trigger.trigger.renamed_update_column(
6626                            column_name,
6627                            new_column_name.clone(),
6628                            epoch.0,
6629                        )?;
6630                    }
6631                }
6632                for role in &mut cat.roles {
6633                    for permission in &mut role.permissions {
6634                        rename_permission_column(
6635                            permission,
6636                            table_name,
6637                            column_name,
6638                            &new_column_name,
6639                        );
6640                    }
6641                }
6642                cat.security_version = cat.security_version.wrapping_add(1);
6643            }
6644        }
6645        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
6646
6647        self.epoch.publish_in_order(epoch);
6648        _epoch_guard.disarm();
6649        Ok(column)
6650    }
6651
6652    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
6653    /// replication. Duration is in nanoseconds.
6654    pub fn set_table_ttl(
6655        &self,
6656        table_name: &str,
6657        column_name: &str,
6658        duration_nanos: u64,
6659    ) -> Result<crate::manifest::TtlPolicy> {
6660        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
6661        Ok(policy.expect("set TTL produces a policy"))
6662    }
6663
6664    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
6665        self.replace_table_ttl(table_name, None)?;
6666        Ok(())
6667    }
6668
6669    fn replace_table_ttl(
6670        &self,
6671        table_name: &str,
6672        requested: Option<(&str, u64)>,
6673    ) -> Result<Option<crate::manifest::TtlPolicy>> {
6674        use crate::wal::DdlOp;
6675        use std::sync::atomic::Ordering;
6676
6677        self.require(&crate::auth::Permission::Ddl)?;
6678        if self.poisoned.load(Ordering::Relaxed) {
6679            return Err(MongrelError::Other(
6680                "database poisoned by fsync error".into(),
6681            ));
6682        }
6683
6684        let _g = self.ddl_lock.lock();
6685        let table_id = {
6686            let cat = self.catalog.read();
6687            cat.live(table_name)
6688                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
6689                .table_id
6690        };
6691        let handle =
6692            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
6693                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
6694            })?;
6695        let mut table = handle.lock();
6696        let policy = match requested {
6697            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
6698            None => None,
6699        };
6700        if table.ttl() == policy {
6701            return Ok(policy);
6702        }
6703
6704        let commit_lock = Arc::clone(&self.commit_lock);
6705        let _c = commit_lock.lock();
6706        let epoch = self.epoch.bump_assigned();
6707        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6708        let txn_id = self.alloc_txn_id();
6709        let policy_json = DdlOp::encode_ttl(policy)?;
6710        let commit_seq = {
6711            let mut wal = self.shared_wal.lock();
6712            wal.append(
6713                txn_id,
6714                table_id,
6715                crate::wal::Op::Ddl(DdlOp::SetTtl {
6716                    table_id,
6717                    policy_json,
6718                }),
6719            )?;
6720            wal.append_commit(txn_id, epoch, &[])?
6721        };
6722        self.group
6723            .await_durable(&self.shared_wal, commit_seq)
6724            .inspect_err(|_| {
6725                self.poisoned.store(true, Ordering::Relaxed);
6726            })?;
6727
6728        table.apply_ttl_policy_at(policy, epoch)?;
6729        self.epoch.publish_in_order(epoch);
6730        _epoch_guard.disarm();
6731        Ok(policy)
6732    }
6733
6734    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
6735    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
6736    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
6737    ///
6738    /// Returns the number of items reclaimed.
6739    pub fn gc(&self) -> Result<usize> {
6740        self.require(&crate::auth::Permission::Ddl)?;
6741        let min_active = self.snapshots.min_active(self.epoch.visible()).0;
6742        let mut reclaimed = 0;
6743
6744        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
6745        let cat = self.catalog.read();
6746        for entry in &cat.tables {
6747            if let TableState::Dropped { at_epoch } = entry.state {
6748                if at_epoch <= min_active {
6749                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
6750                    if tdir.exists() {
6751                        std::fs::remove_dir_all(&tdir)?;
6752                        reclaimed += 1;
6753                    }
6754                }
6755            }
6756        }
6757        drop(cat);
6758
6759        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
6760        // in-flight spill's dir (deleting it would lose the pending run and fail
6761        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
6762        // skip any id still registered in `active_spills`.
6763        let cat = self.catalog.read();
6764        for entry in &cat.tables {
6765            if !matches!(entry.state, TableState::Live) {
6766                continue;
6767            }
6768            let txn_dir = self
6769                .root
6770                .join(TABLES_DIR)
6771                .join(entry.table_id.to_string())
6772                .join("_txn");
6773            if !txn_dir.exists() {
6774                continue;
6775            }
6776            for sub in std::fs::read_dir(&txn_dir)? {
6777                let sub = sub?;
6778                let name = sub.file_name();
6779                let Some(name) = name.to_str() else { continue };
6780                // A non-numeric entry can't belong to a live txn — sweep it.
6781                let is_active = name
6782                    .parse::<u64>()
6783                    .map(|id| self.active_spills.is_active(id))
6784                    .unwrap_or(false);
6785                if is_active {
6786                    continue;
6787                }
6788                std::fs::remove_dir_all(sub.path())?;
6789                reclaimed += 1;
6790            }
6791        }
6792        drop(cat);
6793
6794        let external_names = {
6795            let cat = self.catalog.read();
6796            cat.external_tables
6797                .iter()
6798                .map(|entry| entry.name.clone())
6799                .collect::<std::collections::HashSet<_>>()
6800        };
6801        let vtab_dir = self.root.join(VTAB_DIR);
6802        if vtab_dir.exists() {
6803            for entry in std::fs::read_dir(&vtab_dir)? {
6804                let entry = entry?;
6805                let name = entry.file_name();
6806                let Some(name) = name.to_str() else { continue };
6807                if external_names.contains(name) {
6808                    continue;
6809                }
6810                let path = entry.path();
6811                if path.is_dir() {
6812                    std::fs::remove_dir_all(path)?;
6813                } else {
6814                    std::fs::remove_file(path)?;
6815                }
6816                reclaimed += 1;
6817            }
6818        }
6819
6820        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
6821        // can still need (spec §6.4). Each table deletes its own retired files
6822        // gated on `min_active` and persists its manifest.
6823        let tables = self.tables.read();
6824        for (table_id, handle) in tables.iter() {
6825            let backup_pinned: HashSet<u128> = self
6826                .backup_pins
6827                .lock()
6828                .keys()
6829                .filter_map(|(pinned_table, run_id)| {
6830                    (*pinned_table == *table_id).then_some(*run_id)
6831                })
6832                .collect();
6833            reclaimed += handle
6834                .lock()
6835                .reap_retiring(Epoch(min_active), &backup_pinned)?;
6836        }
6837
6838        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
6839        // segment on every reopen without truncating the prior ones, so rotated
6840        // segments accumulate. Once every live table's committed data is durable
6841        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
6842        // (non-active) segments are redundant for recovery and safe to delete —
6843        // an in-flight txn only ever appends to the active segment, which is
6844        // never deleted.
6845        let all_durable = self.active_spills.is_idle()
6846            && tables.values().all(|h| {
6847                let g = h.lock();
6848                g.memtable_len() == 0 && g.mutable_run_len() == 0
6849            });
6850        drop(tables);
6851        if all_durable {
6852            let retain = self
6853                .replication_wal_retention_segments
6854                .load(std::sync::atomic::Ordering::Relaxed);
6855            reclaimed += self
6856                .shared_wal
6857                .lock()
6858                .gc_segments_retain_recent(u64::MAX, retain)?;
6859        }
6860
6861        Ok(reclaimed)
6862    }
6863
6864    /// Produce a deterministic-stable byte image of the database directory.
6865    ///
6866    /// After `checkpoint()`:
6867    ///   - All pending writes are flushed to sorted runs (no memtable data).
6868    ///   - Each table is compacted to a single sorted run (no run fragmentation).
6869    ///   - All non-active WAL segments are deleted (data is durable in runs).
6870    ///   - The active WAL segment is rotated to a fresh empty segment.
6871    ///   - Dropped-table directories are removed.
6872    ///   - All manifests + catalog are persisted.
6873    ///
6874    /// The resulting directory is byte-stable: `git add` captures a snapshot
6875    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
6876    /// no unbounded segment growth, no mutable-run spill files.
6877    ///
6878    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
6879    /// It does NOT clear the exclusive lock — the caller still owns the
6880    /// database handle.
6881    pub fn checkpoint(&self) -> Result<()> {
6882        // 1. Force-flush every table's pending writes to sorted runs.
6883        self.close()?;
6884
6885        // 2. Compact every table to a single run (merge all fragments).
6886        let _ = self.compact()?;
6887
6888        // 3. GC everything: dropped-table dirs, stale _txn dirs, retired runs,
6889        //    and all WAL segments whose data is now durable in runs.
6890        self.gc()?;
6891
6892        // 4. Reap ALL WAL segments (all data is durable in runs after flush +
6893        //    compact). Delete every segment file, then the reopen creates a
6894        //    fresh empty one via SharedWal::open. We can't use gc_segments alone
6895        //    because it skips the active segment — and leaving a stale active
6896        //    segment with pre-checkpoint tail bytes causes a magic-mismatch or
6897        //    truncated-read panic on reopen.
6898        {
6899            let wal = self.shared_wal.lock();
6900            let active = wal.active_segment_no();
6901            drop(wal);
6902            // Remove every segment file including the active one.
6903            let wal_dir = self.root.join("_wal");
6904            if wal_dir.exists() {
6905                for entry in std::fs::read_dir(&wal_dir)? {
6906                    let entry = entry?;
6907                    let path = entry.path();
6908                    if path.extension().is_some_and(|ext| ext == "wal") {
6909                        let _ = std::fs::remove_file(&path);
6910                    }
6911                }
6912            }
6913            let _ = active; // tracked for debugging
6914        }
6915
6916        // 5. Persist the catalog with the bumped next_segment_no.
6917        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
6918
6919        // 6. Persist every table's manifest (force_flush/compact already did
6920        //    this, but a final pass ensures consistency after WAL rotation).
6921        let tables = self.tables.read();
6922        let visible = self.epoch.visible();
6923        for handle in tables.values() {
6924            handle.lock().persist_manifest(visible)?;
6925        }
6926
6927        Ok(())
6928    }
6929    fn alloc_txn_id(&self) -> u64 {
6930        let mut g = self.next_txn_id.lock();
6931        let id = *g;
6932        *g = g.wrapping_add(1);
6933        id
6934    }
6935
6936    /// Set the per-table spill threshold (bytes). When a transaction's staged
6937    /// bytes for a single table exceed this, the rows are written as a
6938    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
6939    pub fn set_spill_threshold(&self, bytes: u64) {
6940        self.spill_threshold
6941            .store(bytes, std::sync::atomic::Ordering::Relaxed);
6942    }
6943
6944    /// Test-only: install a hook invoked after a transaction writes its spill
6945    /// runs but before the sequencer, so a test can race `gc()` against an
6946    /// in-flight spill. Not part of the stable API.
6947    #[doc(hidden)]
6948    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
6949        *self.spill_hook.lock() = Some(Box::new(f));
6950    }
6951
6952    /// Test-only: pause an online backup after its consistent boundary is
6953    /// captured but before the pinned immutable runs are copied.
6954    #[doc(hidden)]
6955    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
6956        *self.backup_hook.lock() = Some(Box::new(f));
6957    }
6958
6959    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
6960    /// this stays well below the number of committed transactions when commits
6961    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
6962    #[doc(hidden)]
6963    pub fn __wal_group_sync_count(&self) -> u64 {
6964        self.shared_wal.lock().group_sync_count()
6965    }
6966
6967    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
6968    /// contract that an fsync error would trigger in production.
6969    #[doc(hidden)]
6970    pub fn __poison(&self) {
6971        self.poisoned
6972            .store(true, std::sync::atomic::Ordering::Relaxed);
6973    }
6974
6975    /// Verify multi-table integrity (spec §16). For every live table this:
6976    /// authenticates the manifest; opens each `RunRef`'s file through
6977    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
6978    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
6979    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
6980    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
6981    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
6982    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
6983    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
6984    ///
6985    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
6986    /// full body, so this is an integrity tool, not a hot path.
6987    pub fn check(&self) -> Vec<CheckIssue> {
6988        let mut issues = Vec::new();
6989        let cat = self.catalog.read();
6990        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
6991        for entry in &cat.tables {
6992            if !matches!(entry.state, TableState::Live) {
6993                continue;
6994            }
6995            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
6996            let mut err = |sev: &str, desc: String| {
6997                issues.push(CheckIssue {
6998                    table_id: entry.table_id,
6999                    table_name: entry.name.clone(),
7000                    severity: sev.into(),
7001                    description: desc,
7002                });
7003            };
7004            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
7005                Ok(m) => m,
7006                Err(e) => {
7007                    err("error", format!("manifest read failed: {e}"));
7008                    continue;
7009                }
7010            };
7011            if m.flushed_epoch > m.current_epoch {
7012                err(
7013                    "error",
7014                    format!(
7015                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
7016                        m.flushed_epoch, m.current_epoch
7017                    ),
7018                );
7019            }
7020
7021            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
7022            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
7023            for rr in &m.runs {
7024                referenced.insert(rr.run_id);
7025                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
7026                if !run_path.exists() {
7027                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
7028                    continue;
7029                }
7030                match crate::sorted_run::RunReader::open(
7031                    &run_path,
7032                    entry.schema.clone(),
7033                    self.kek.clone(),
7034                ) {
7035                    Ok(reader) => {
7036                        if reader.row_count() as u64 != rr.row_count {
7037                            err(
7038                                "error",
7039                                format!(
7040                                    "run r-{} row count mismatch: manifest {} vs run {}",
7041                                    rr.run_id,
7042                                    rr.row_count,
7043                                    reader.row_count()
7044                                ),
7045                            );
7046                        }
7047                    }
7048                    Err(e) => {
7049                        err(
7050                            "error",
7051                            format!("run r-{} integrity check failed: {e}", rr.run_id),
7052                        );
7053                    }
7054                }
7055            }
7056
7057            // Compaction-superseded runs awaiting retention-gated deletion are
7058            // tracked in `retiring`; their files are expected on disk, so they
7059            // are not orphans.
7060            for r in &m.retiring {
7061                referenced.insert(r.run_id);
7062            }
7063
7064            // Orphan `.sr` files present on disk but absent from the manifest.
7065            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
7066                for ent in rd.flatten() {
7067                    let p = ent.path();
7068                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
7069                        continue;
7070                    }
7071                    let run_id = p
7072                        .file_stem()
7073                        .and_then(|s| s.to_str())
7074                        .and_then(|s| s.strip_prefix("r-"))
7075                        .and_then(|s| s.parse::<u128>().ok());
7076                    if let Some(id) = run_id {
7077                        if !referenced.contains(&id) {
7078                            err(
7079                                "warning",
7080                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
7081                            );
7082                        }
7083                    }
7084                }
7085            }
7086        }
7087
7088        let external_names = cat
7089            .external_tables
7090            .iter()
7091            .map(|entry| entry.name.clone())
7092            .collect::<std::collections::HashSet<_>>();
7093        let vtab_dir = self.root.join(VTAB_DIR);
7094        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
7095            for entry in entries.flatten() {
7096                let name = entry.file_name();
7097                let Some(name) = name.to_str() else { continue };
7098                if !external_names.contains(name) {
7099                    issues.push(CheckIssue {
7100                        table_id: EXTERNAL_TABLE_ID,
7101                        table_name: name.to_string(),
7102                        severity: "warning".into(),
7103                        description: format!(
7104                            "orphan external table state entry {:?} not referenced by the catalog",
7105                            entry.path()
7106                        ),
7107                    });
7108                }
7109            }
7110        }
7111
7112        // WAL retention / integrity invariant (spec §16): every on-disk WAL
7113        // segment must open (header magic + version, and the frame cipher must
7114        // be derivable for an encrypted WAL). A segment that won't open is
7115        // corrupt or truncated and would break crash recovery. `table_id` is
7116        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
7117        // never confuses a WAL issue with a real table.
7118        for (seg, msg) in self.shared_wal.lock().verify_segments() {
7119            issues.push(CheckIssue {
7120                table_id: WAL_TABLE_ID,
7121                table_name: "<wal>".into(),
7122                severity: "error".into(),
7123                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
7124            });
7125        }
7126        issues
7127    }
7128
7129    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
7130    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
7131    /// unmounts them from the live table map so the DB still opens.
7132    pub fn doctor(&self) -> Result<Vec<u64>> {
7133        // Hold the DDL lock for the whole operation to prevent concurrent
7134        // create_table/drop_table from racing the catalog/dir mutation.
7135        let _ddl = self.ddl_lock.lock();
7136        let issues = self.check();
7137        // A corrupt WAL segment is reported as an error but is NOT a table
7138        // problem — quarantining an innocent table cannot fix it (and the first
7139        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
7140        // them disjoint). The admin must address WAL corruption manually.
7141        let bad_tables: std::collections::HashSet<u64> = issues
7142            .iter()
7143            .filter(|i| {
7144                i.severity == "error"
7145                    && i.table_id != WAL_TABLE_ID
7146                    && i.table_id != EXTERNAL_TABLE_ID
7147            })
7148            .map(|i| i.table_id)
7149            .collect();
7150        if bad_tables.is_empty() {
7151            return Ok(Vec::new());
7152        }
7153
7154        let qdir = self.root.join("_quarantine");
7155        std::fs::create_dir_all(&qdir)?;
7156        let mut quarantined = Vec::new();
7157        for &table_id in &bad_tables {
7158            let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
7159            if tdir.exists() {
7160                let dest = qdir.join(table_id.to_string());
7161                std::fs::rename(&tdir, &dest)?;
7162            }
7163            {
7164                let mut cat = self.catalog.write();
7165                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
7166                    entry.state = TableState::Dropped {
7167                        at_epoch: self.epoch.visible().0,
7168                    };
7169                }
7170            }
7171            // Unmount the live handle so no further access reaches the moved dir.
7172            self.tables.write().remove(&table_id);
7173            quarantined.push(table_id);
7174        }
7175        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
7176        Ok(quarantined)
7177    }
7178
7179    /// The DB-wide KEK (if encrypted).
7180    #[allow(dead_code)]
7181    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
7182        self.kek.as_ref()
7183    }
7184
7185    /// Shared epoch authority (used by the transaction layer in P2).
7186    #[allow(dead_code)]
7187    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
7188        &self.epoch
7189    }
7190
7191    /// Shared snapshot registry (used by GC in P3.6).
7192    #[allow(dead_code)]
7193    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
7194        &self.snapshots
7195    }
7196}
7197
7198fn external_state_dir(root: &Path, name: &str) -> PathBuf {
7199    root.join(VTAB_DIR).join(name)
7200}
7201
7202fn filter_ignored_staging(
7203    staging: Vec<(u64, crate::txn::Staged)>,
7204    ignored_indices: &std::collections::BTreeSet<usize>,
7205) -> Vec<(u64, crate::txn::Staged)> {
7206    if ignored_indices.is_empty() {
7207        return staging;
7208    }
7209    staging
7210        .into_iter()
7211        .enumerate()
7212        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
7213        .collect()
7214}
7215
7216fn external_state_file(root: &Path, name: &str) -> PathBuf {
7217    external_state_dir(root, name).join("state.json")
7218}
7219
7220fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
7221    let path = external_state_file(root, name);
7222    match std::fs::read(path) {
7223        Ok(bytes) => Ok(bytes),
7224        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
7225        Err(e) => Err(e.into()),
7226    }
7227}
7228
7229fn current_external_state_bytes(
7230    root: &Path,
7231    external_states: &[(String, Vec<u8>)],
7232    name: &str,
7233) -> Result<Vec<u8>> {
7234    for (table, state) in external_states.iter().rev() {
7235        if table == name {
7236            return Ok(state.clone());
7237        }
7238    }
7239    read_external_state_file(root, name)
7240}
7241
7242fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
7243    let mut out = external_states;
7244    dedup_external_states_in_place(&mut out);
7245    out
7246}
7247
7248fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
7249    let mut seen = std::collections::HashSet::new();
7250    let mut out = Vec::with_capacity(external_states.len());
7251    for (name, state) in std::mem::take(external_states).into_iter().rev() {
7252        if seen.insert(name.clone()) {
7253            out.push((name, state));
7254        }
7255    }
7256    out.reverse();
7257    *external_states = out;
7258}
7259
7260fn prepare_external_state_file(
7261    root: &Path,
7262    name: &str,
7263    state: &[u8],
7264    txn_id: u64,
7265) -> Result<PathBuf> {
7266    let dir = external_state_dir(root, name);
7267    std::fs::create_dir_all(&dir)?;
7268    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
7269    {
7270        let mut file = std::fs::File::create(&pending)?;
7271        file.write_all(state)?;
7272        file.sync_all()?;
7273    }
7274    Ok(pending)
7275}
7276
7277fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
7278    let path = external_state_file(root, name);
7279    std::fs::rename(pending, &path)?;
7280    if let Ok(dir) = std::fs::File::open(external_state_dir(root, name)) {
7281        let _ = dir.sync_all();
7282    }
7283    Ok(())
7284}
7285
7286fn write_external_state_file(root: &Path, name: &str, state: &[u8]) -> Result<()> {
7287    let pending = prepare_external_state_file(root, name, state, 0)?;
7288    publish_external_state_file(root, name, &pending)
7289}
7290
7291/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
7292///
7293/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
7294/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
7295/// 2 applies each committed data record (Put/Delete) to its table at the commit
7296/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
7297/// durable in a sorted run). Finally the shared epoch authority is raised to the
7298/// max committed epoch so the next commit continues monotonically.
7299fn recover_shared_wal(
7300    root: &Path,
7301    tables: &HashMap<u64, TableHandle>,
7302    epoch: &EpochAuthority,
7303    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
7304) -> Result<()> {
7305    use crate::memtable::Row;
7306    use crate::rowid::RowId;
7307    use crate::wal::{Op, SharedWal};
7308
7309    let records = SharedWal::replay_with_dek(root, wal_dek)?;
7310
7311    // Pass 1: committed-txn outcomes + collect spilled-run info.
7312    let mut committed: HashMap<u64, u64> = HashMap::new();
7313    let mut spilled_to_link: Vec<(
7314        u64, /*txn_id*/
7315        u64, /*epoch*/
7316        Vec<crate::wal::AddedRun>,
7317    )> = Vec::new();
7318    for r in &records {
7319        if let Op::TxnCommit {
7320            epoch: ce,
7321            ref added_runs,
7322        } = r.op
7323        {
7324            committed.insert(r.txn_id, ce);
7325            if !added_runs.is_empty() {
7326                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
7327            }
7328        }
7329    }
7330    let truncated_transactions: HashSet<(u64, u64)> = records
7331        .iter()
7332        .filter_map(|record| {
7333            committed.get(&record.txn_id)?;
7334            match record.op {
7335                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
7336                _ => None,
7337            }
7338        })
7339        .collect();
7340
7341    // Pass 2: stage data per table, gated by flushed_epoch.
7342    type TableStage = (Vec<Row>, Vec<(RowId, Epoch)>, Option<Epoch>, Epoch);
7343    let mut stage: HashMap<u64, TableStage> = HashMap::new();
7344    let mut max_epoch = epoch.visible().0;
7345    for r in records {
7346        let Some(&ce) = committed.get(&r.txn_id) else {
7347            continue; // aborted / in-flight — discard
7348        };
7349        let commit_epoch = Epoch(ce);
7350        max_epoch = max_epoch.max(ce);
7351        match r.op {
7352            Op::Put { table_id, rows } => {
7353                // Skip if this table already flushed past the commit epoch.
7354                let skip = tables
7355                    .get(&table_id)
7356                    .map(|h| h.lock().flushed_epoch() >= ce)
7357                    .unwrap_or(true);
7358                if skip {
7359                    continue;
7360                }
7361                let rows: Vec<Row> = match bincode::deserialize(&rows) {
7362                    Ok(v) => v,
7363                    Err(_) => continue,
7364                };
7365                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
7366                // at pending_epoch which equals the commit epoch, but be robust).
7367                let rows: Vec<Row> = rows
7368                    .into_iter()
7369                    .map(|mut row| {
7370                        row.committed_epoch = commit_epoch;
7371                        row
7372                    })
7373                    .collect();
7374                let entry = stage
7375                    .entry(table_id)
7376                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
7377                entry.0.extend(rows);
7378                entry.3 = commit_epoch;
7379            }
7380            Op::Delete { table_id, row_ids } => {
7381                let skip = tables
7382                    .get(&table_id)
7383                    .map(|h| h.lock().flushed_epoch() >= ce)
7384                    .unwrap_or(true);
7385                if skip {
7386                    continue;
7387                }
7388                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
7389                let entry = stage
7390                    .entry(table_id)
7391                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
7392                entry.1.extend(dels);
7393                entry.3 = commit_epoch;
7394            }
7395            Op::TruncateTable { table_id } => {
7396                let skip = tables
7397                    .get(&table_id)
7398                    .map(|h| h.lock().flushed_epoch() >= ce)
7399                    .unwrap_or(true);
7400                if skip {
7401                    continue;
7402                }
7403                stage.insert(
7404                    table_id,
7405                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
7406                );
7407            }
7408            Op::ExternalTableState { name, state } => {
7409                write_external_state_file(root, &name, &state)?;
7410            }
7411            Op::Flush { .. }
7412            | Op::TxnCommit { .. }
7413            | Op::TxnAbort
7414            | Op::Ddl(_)
7415            | Op::BeforeImage { .. }
7416            | Op::CommitTimestamp { .. } => {}
7417        }
7418    }
7419    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
7420        let Some(handle) = tables.get(&table_id) else {
7421            continue;
7422        };
7423        let mut t = handle.lock();
7424        if let Some(epoch) = truncate_epoch {
7425            t.apply_truncate(epoch)?;
7426        }
7427        t.recover_apply(rows, deletes)?;
7428        // The WAL can be newer than the copied/persisted manifest after a
7429        // crash or replication apply. Rebuild O(1) count metadata from the
7430        // recovered state before endorsing the commit epoch in the manifest.
7431        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
7432        t.live_count = rows.len() as u64;
7433        t.persist_manifest(table_epoch)?;
7434    }
7435
7436    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
7437    // between TxnCommit sync and the publish phase leaves the run in
7438    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
7439    for (txn_id, ce, added_runs) in &spilled_to_link {
7440        for ar in added_runs {
7441            let Some(handle) = tables.get(&ar.table_id) else {
7442                continue;
7443            };
7444            let mut t = handle.lock();
7445            let dest = t.run_path(ar.run_id as u64);
7446            if !dest.exists() {
7447                let pending = root
7448                    .join(TABLES_DIR)
7449                    .join(ar.table_id.to_string())
7450                    .join("_txn")
7451                    .join(txn_id.to_string())
7452                    .join(format!("r-{}.sr", ar.run_id));
7453                if pending.exists() {
7454                    if let Some(parent) = pending.parent() {
7455                        std::fs::rename(&pending, &dest)?;
7456                        let _ = std::fs::remove_dir_all(parent);
7457                    }
7458                }
7459            }
7460            // Only link a run whose file is actually present, and never re-link
7461            // one the publish phase already persisted into the manifest (which is
7462            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
7463            // until segment GC). `recover_spilled_run` is idempotent + reconciles
7464            // `live_count`/indexes only when the run is genuinely new.
7465            if t.run_path(ar.run_id as u64).exists() {
7466                let linked = t.recover_spilled_run(crate::manifest::RunRef {
7467                    run_id: ar.run_id,
7468                    level: ar.level,
7469                    epoch_created: *ce,
7470                    row_count: ar.row_count,
7471                });
7472                let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
7473                if replaced {
7474                    t.set_flushed_epoch(Epoch(*ce));
7475                }
7476                if linked || replaced {
7477                    t.persist_manifest(Epoch(*ce))?;
7478                }
7479            }
7480        }
7481    }
7482
7483    epoch.advance_recovered(Epoch(max_epoch));
7484    Ok(())
7485}
7486
7487fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
7488    match condition {
7489        ProcedureCondition::Pk { .. } => {
7490            if schema.primary_key().is_none() {
7491                return Err(MongrelError::InvalidArgument(
7492                    "procedure condition Pk references a table without a primary key".into(),
7493                ));
7494            }
7495        }
7496        ProcedureCondition::BitmapEq { column_id, .. }
7497        | ProcedureCondition::BitmapIn { column_id, .. }
7498        | ProcedureCondition::Range { column_id, .. }
7499        | ProcedureCondition::RangeF64 { column_id, .. }
7500        | ProcedureCondition::IsNull { column_id }
7501        | ProcedureCondition::IsNotNull { column_id }
7502        | ProcedureCondition::FmContains { column_id, .. } => {
7503            validate_column_id(*column_id, schema)?;
7504        }
7505    }
7506    Ok(())
7507}
7508
7509fn bind_procedure_args(
7510    procedure: &StoredProcedure,
7511    mut args: HashMap<String, crate::Value>,
7512) -> Result<HashMap<String, crate::Value>> {
7513    let mut out = HashMap::new();
7514    for param in &procedure.params {
7515        let value = match args.remove(&param.name) {
7516            Some(value) => value,
7517            None => param.default.clone().ok_or_else(|| {
7518                MongrelError::InvalidArgument(format!(
7519                    "missing required procedure parameter {:?}",
7520                    param.name
7521                ))
7522            })?,
7523        };
7524        if !param.nullable && matches!(value, crate::Value::Null) {
7525            return Err(MongrelError::InvalidArgument(format!(
7526                "procedure parameter {:?} must not be NULL",
7527                param.name
7528            )));
7529        }
7530        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
7531            return Err(MongrelError::InvalidArgument(format!(
7532                "procedure parameter {:?} has wrong type",
7533                param.name
7534            )));
7535        }
7536        out.insert(param.name.clone(), value);
7537    }
7538    if let Some(extra) = args.keys().next() {
7539        return Err(MongrelError::InvalidArgument(format!(
7540            "unknown procedure parameter {extra:?}"
7541        )));
7542    }
7543    Ok(out)
7544}
7545
7546fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
7547    matches!(
7548        (value, ty),
7549        (crate::Value::Bool(_), crate::TypeId::Bool)
7550            | (crate::Value::Int64(_), crate::TypeId::Int8)
7551            | (crate::Value::Int64(_), crate::TypeId::Int16)
7552            | (crate::Value::Int64(_), crate::TypeId::Int32)
7553            | (crate::Value::Int64(_), crate::TypeId::Int64)
7554            | (crate::Value::Int64(_), crate::TypeId::UInt8)
7555            | (crate::Value::Int64(_), crate::TypeId::UInt16)
7556            | (crate::Value::Int64(_), crate::TypeId::UInt32)
7557            | (crate::Value::Int64(_), crate::TypeId::UInt64)
7558            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
7559            | (crate::Value::Int64(_), crate::TypeId::Date32)
7560            | (crate::Value::Float64(_), crate::TypeId::Float32)
7561            | (crate::Value::Float64(_), crate::TypeId::Float64)
7562            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
7563            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
7564    )
7565}
7566
7567fn eval_cells(
7568    cells: &[crate::procedure::ProcedureCell],
7569    args: &HashMap<String, crate::Value>,
7570    outputs: &HashMap<String, ProcedureCallOutput>,
7571) -> Result<Vec<(u16, crate::Value)>> {
7572    cells
7573        .iter()
7574        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
7575        .collect()
7576}
7577
7578fn eval_condition(
7579    condition: &ProcedureCondition,
7580    args: &HashMap<String, crate::Value>,
7581    outputs: &HashMap<String, ProcedureCallOutput>,
7582) -> Result<crate::Condition> {
7583    Ok(match condition {
7584        ProcedureCondition::Pk { value } => {
7585            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
7586        }
7587        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
7588            column_id: *column_id,
7589            value: eval_value(value, args, outputs)?.encode_key(),
7590        },
7591        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
7592            column_id: *column_id,
7593            values: values
7594                .iter()
7595                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
7596                .collect::<Result<Vec<_>>>()?,
7597        },
7598        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
7599            column_id: *column_id,
7600            lo: expect_i64(eval_value(lo, args, outputs)?)?,
7601            hi: expect_i64(eval_value(hi, args, outputs)?)?,
7602        },
7603        ProcedureCondition::RangeF64 {
7604            column_id,
7605            lo,
7606            lo_inclusive,
7607            hi,
7608            hi_inclusive,
7609        } => crate::Condition::RangeF64 {
7610            column_id: *column_id,
7611            lo: expect_f64(eval_value(lo, args, outputs)?)?,
7612            lo_inclusive: *lo_inclusive,
7613            hi: expect_f64(eval_value(hi, args, outputs)?)?,
7614            hi_inclusive: *hi_inclusive,
7615        },
7616        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
7617            column_id: *column_id,
7618        },
7619        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
7620            column_id: *column_id,
7621        },
7622        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
7623            column_id: *column_id,
7624            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
7625        },
7626    })
7627}
7628
7629fn eval_value(
7630    value: &ProcedureValue,
7631    args: &HashMap<String, crate::Value>,
7632    outputs: &HashMap<String, ProcedureCallOutput>,
7633) -> Result<crate::Value> {
7634    match value {
7635        ProcedureValue::Literal(value) => Ok(value.clone()),
7636        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
7637            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
7638        }),
7639        ProcedureValue::StepScalar(id) => match outputs.get(id) {
7640            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
7641            _ => Err(MongrelError::InvalidArgument(format!(
7642                "procedure step {id:?} did not return a scalar"
7643            ))),
7644        },
7645        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
7646            Err(MongrelError::InvalidArgument(
7647                "row-valued procedure reference cannot be used as a scalar".into(),
7648            ))
7649        }
7650        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
7651            "structured procedure value cannot be used as a scalar cell".into(),
7652        )),
7653    }
7654}
7655
7656fn eval_return_output(
7657    value: &ProcedureValue,
7658    args: &HashMap<String, crate::Value>,
7659    outputs: &HashMap<String, ProcedureCallOutput>,
7660) -> Result<ProcedureCallOutput> {
7661    match value {
7662        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
7663        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
7664            args.get(name).cloned().ok_or_else(|| {
7665                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
7666            })?,
7667        )),
7668        ProcedureValue::StepRows(id)
7669        | ProcedureValue::StepRow(id)
7670        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
7671            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
7672        }),
7673        ProcedureValue::Object(fields) => {
7674            let mut out = Vec::with_capacity(fields.len());
7675            for (name, value) in fields {
7676                out.push((name.clone(), eval_return_output(value, args, outputs)?));
7677            }
7678            Ok(ProcedureCallOutput::Object(out))
7679        }
7680        ProcedureValue::Array(values) => {
7681            let mut out = Vec::with_capacity(values.len());
7682            for value in values {
7683                out.push(eval_return_output(value, args, outputs)?);
7684            }
7685            Ok(ProcedureCallOutput::Array(out))
7686        }
7687    }
7688}
7689
7690fn expect_i64(value: crate::Value) -> Result<i64> {
7691    match value {
7692        crate::Value::Int64(value) => Ok(value),
7693        _ => Err(MongrelError::InvalidArgument(
7694            "procedure value must be Int64".into(),
7695        )),
7696    }
7697}
7698
7699fn expect_f64(value: crate::Value) -> Result<f64> {
7700    match value {
7701        crate::Value::Float64(value) => Ok(value),
7702        _ => Err(MongrelError::InvalidArgument(
7703            "procedure value must be Float64".into(),
7704        )),
7705    }
7706}
7707
7708fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
7709    match value {
7710        crate::Value::Bytes(value) => Ok(value),
7711        _ => Err(MongrelError::InvalidArgument(
7712            "procedure value must be Bytes".into(),
7713        )),
7714    }
7715}
7716
7717fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
7718    if schema.columns.iter().any(|c| c.id == column_id) {
7719        Ok(())
7720    } else {
7721        Err(MongrelError::InvalidArgument(format!(
7722            "unknown column id {column_id}"
7723        )))
7724    }
7725}
7726
7727fn trigger_matches_event(
7728    trigger: &StoredTrigger,
7729    event: &WriteEvent,
7730    cat: &Catalog,
7731) -> Result<bool> {
7732    if trigger.event != event.kind {
7733        return Ok(false);
7734    }
7735    let TriggerTarget::Table(target) = &trigger.target else {
7736        return Ok(false);
7737    };
7738    if target != &event.table {
7739        return Ok(false);
7740    }
7741    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
7742        let schema = &cat
7743            .live(target)
7744            .ok_or_else(|| {
7745                MongrelError::InvalidArgument(format!(
7746                    "trigger {:?} references unknown table {target:?}",
7747                    trigger.name
7748                ))
7749            })?
7750            .schema;
7751        let mut watched = Vec::with_capacity(trigger.update_of.len());
7752        for name in &trigger.update_of {
7753            let col = schema.column(name).ok_or_else(|| {
7754                MongrelError::InvalidArgument(format!(
7755                    "trigger {:?} references unknown UPDATE OF column {name:?}",
7756                    trigger.name
7757                ))
7758            })?;
7759            watched.push(col.id);
7760        }
7761        if !event
7762            .changed_columns
7763            .iter()
7764            .any(|column_id| watched.contains(column_id))
7765        {
7766            return Ok(false);
7767        }
7768    }
7769    Ok(true)
7770}
7771
7772fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
7773    let mut ids = std::collections::BTreeSet::new();
7774    if let Some(old) = old {
7775        ids.extend(old.columns.keys().copied());
7776    }
7777    if let Some(new) = new {
7778        ids.extend(new.columns.keys().copied());
7779    }
7780    ids.into_iter()
7781        .filter(|id| {
7782            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
7783        })
7784        .collect()
7785}
7786
7787fn eval_trigger_cells(
7788    cells: &[crate::trigger::TriggerCell],
7789    event: &WriteEvent,
7790    selected: Option<&TriggerRowImage>,
7791) -> Result<Vec<(u16, Value)>> {
7792    cells
7793        .iter()
7794        .map(|cell| {
7795            Ok((
7796                cell.column_id,
7797                eval_trigger_value(&cell.value, event, selected)?,
7798            ))
7799        })
7800        .collect()
7801}
7802
7803fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
7804    match expr {
7805        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
7806            Value::Bool(value) => Ok(value),
7807            Value::Null => Ok(false),
7808            other => Err(MongrelError::InvalidArgument(format!(
7809                "trigger WHEN value must be boolean, got {other:?}"
7810            ))),
7811        },
7812        TriggerExpr::Eq { left, right } => Ok(values_equal(
7813            &eval_trigger_value(left, event, None)?,
7814            &eval_trigger_value(right, event, None)?,
7815        )),
7816        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
7817            &eval_trigger_value(left, event, None)?,
7818            &eval_trigger_value(right, event, None)?,
7819        )),
7820        TriggerExpr::Lt { left, right } => match value_order(
7821            &eval_trigger_value(left, event, None)?,
7822            &eval_trigger_value(right, event, None)?,
7823        ) {
7824            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
7825            None => Ok(false),
7826        },
7827        TriggerExpr::Lte { left, right } => match value_order(
7828            &eval_trigger_value(left, event, None)?,
7829            &eval_trigger_value(right, event, None)?,
7830        ) {
7831            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
7832            None => Ok(false),
7833        },
7834        TriggerExpr::Gt { left, right } => match value_order(
7835            &eval_trigger_value(left, event, None)?,
7836            &eval_trigger_value(right, event, None)?,
7837        ) {
7838            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
7839            None => Ok(false),
7840        },
7841        TriggerExpr::Gte { left, right } => match value_order(
7842            &eval_trigger_value(left, event, None)?,
7843            &eval_trigger_value(right, event, None)?,
7844        ) {
7845            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
7846            None => Ok(false),
7847        },
7848        TriggerExpr::IsNull(value) => Ok(matches!(
7849            eval_trigger_value(value, event, None)?,
7850            Value::Null
7851        )),
7852        TriggerExpr::IsNotNull(value) => Ok(!matches!(
7853            eval_trigger_value(value, event, None)?,
7854            Value::Null
7855        )),
7856        TriggerExpr::And { left, right } => {
7857            if !eval_trigger_expr(left, event)? {
7858                Ok(false)
7859            } else {
7860                Ok(eval_trigger_expr(right, event)?)
7861            }
7862        }
7863        TriggerExpr::Or { left, right } => {
7864            if eval_trigger_expr(left, event)? {
7865                Ok(true)
7866            } else {
7867                Ok(eval_trigger_expr(right, event)?)
7868            }
7869        }
7870        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
7871    }
7872}
7873
7874fn eval_trigger_condition(
7875    condition: &TriggerCondition,
7876    event: &WriteEvent,
7877    selected: &TriggerRowImage,
7878    schema: &Schema,
7879) -> Result<bool> {
7880    match condition {
7881        TriggerCondition::Pk { value } => {
7882            let pk = schema.primary_key().ok_or_else(|| {
7883                MongrelError::InvalidArgument(
7884                    "trigger condition Pk references a table without a primary key".into(),
7885                )
7886            })?;
7887            let lhs = eval_trigger_value(value, event, Some(selected))?;
7888            Ok(values_equal(
7889                &lhs,
7890                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
7891            ))
7892        }
7893        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
7894            selected.columns.get(column_id).unwrap_or(&Value::Null),
7895            &eval_trigger_value(value, event, Some(selected))?,
7896        )),
7897        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
7898            selected.columns.get(column_id).unwrap_or(&Value::Null),
7899            &eval_trigger_value(value, event, Some(selected))?,
7900        )),
7901        TriggerCondition::Lt { column_id, value } => match value_order(
7902            selected.columns.get(column_id).unwrap_or(&Value::Null),
7903            &eval_trigger_value(value, event, Some(selected))?,
7904        ) {
7905            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
7906            None => Ok(false),
7907        },
7908        TriggerCondition::Lte { column_id, value } => match value_order(
7909            selected.columns.get(column_id).unwrap_or(&Value::Null),
7910            &eval_trigger_value(value, event, Some(selected))?,
7911        ) {
7912            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
7913            None => Ok(false),
7914        },
7915        TriggerCondition::Gt { column_id, value } => match value_order(
7916            selected.columns.get(column_id).unwrap_or(&Value::Null),
7917            &eval_trigger_value(value, event, Some(selected))?,
7918        ) {
7919            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
7920            None => Ok(false),
7921        },
7922        TriggerCondition::Gte { column_id, value } => match value_order(
7923            selected.columns.get(column_id).unwrap_or(&Value::Null),
7924            &eval_trigger_value(value, event, Some(selected))?,
7925        ) {
7926            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
7927            None => Ok(false),
7928        },
7929        TriggerCondition::IsNull { column_id } => Ok(matches!(
7930            selected.columns.get(column_id),
7931            None | Some(Value::Null)
7932        )),
7933        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
7934            selected.columns.get(column_id),
7935            None | Some(Value::Null)
7936        )),
7937        TriggerCondition::And { left, right } => {
7938            if !eval_trigger_condition(left, event, selected, schema)? {
7939                Ok(false)
7940            } else {
7941                Ok(eval_trigger_condition(right, event, selected, schema)?)
7942            }
7943        }
7944        TriggerCondition::Or { left, right } => {
7945            if eval_trigger_condition(left, event, selected, schema)? {
7946                Ok(true)
7947            } else {
7948                Ok(eval_trigger_condition(right, event, selected, schema)?)
7949            }
7950        }
7951        TriggerCondition::Not(condition) => {
7952            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
7953        }
7954    }
7955}
7956
7957fn eval_trigger_value(
7958    value: &TriggerValue,
7959    event: &WriteEvent,
7960    selected: Option<&TriggerRowImage>,
7961) -> Result<Value> {
7962    match value {
7963        TriggerValue::Literal(value) => Ok(value.clone()),
7964        TriggerValue::NewColumn(column_id) => event
7965            .new
7966            .as_ref()
7967            .and_then(|row| row.columns.get(column_id))
7968            .cloned()
7969            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
7970        TriggerValue::OldColumn(column_id) => event
7971            .old
7972            .as_ref()
7973            .and_then(|row| row.columns.get(column_id))
7974            .cloned()
7975            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
7976        TriggerValue::SelectedColumn(column_id) => selected
7977            .and_then(|row| row.columns.get(column_id))
7978            .cloned()
7979            .ok_or_else(|| {
7980                MongrelError::InvalidArgument("SELECTED column is not available".into())
7981            }),
7982    }
7983}
7984
7985fn values_equal(left: &Value, right: &Value) -> bool {
7986    match (left, right) {
7987        (Value::Null, Value::Null) => true,
7988        (Value::Bool(a), Value::Bool(b)) => a == b,
7989        (Value::Int64(a), Value::Int64(b)) => a == b,
7990        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
7991        (Value::Bytes(a), Value::Bytes(b)) => a == b,
7992        (Value::Embedding(a), Value::Embedding(b)) => {
7993            a.len() == b.len()
7994                && a.iter()
7995                    .zip(b.iter())
7996                    .all(|(a, b)| a.to_bits() == b.to_bits())
7997        }
7998        _ => false,
7999    }
8000}
8001
8002fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
8003    match (left, right) {
8004        (Value::Null, _) | (_, Value::Null) => None,
8005        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
8006        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
8007        // Cross-type Int64/Float64 comparison coerces the integer to f64.
8008        // This matches the spec but can lose precision for i64 values above 2^53.
8009        (Value::Int64(a), Value::Float64(b)) => {
8010            let af = *a as f64;
8011            Some(af.total_cmp(b))
8012        }
8013        // Cross-type Int64/Float64 comparison coerces the integer to f64.
8014        // This matches the spec but can lose precision for i64 values above 2^53.
8015        (Value::Float64(a), Value::Int64(b)) => {
8016            let bf = *b as f64;
8017            Some(a.total_cmp(&bf))
8018        }
8019        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
8020        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
8021        (Value::Embedding(_), Value::Embedding(_)) => None,
8022        _ => None,
8023    }
8024}
8025
8026fn trigger_message(value: Value) -> String {
8027    match value {
8028        Value::Null => "NULL".into(),
8029        Value::Bool(value) => value.to_string(),
8030        Value::Int64(value) => value.to_string(),
8031        Value::Float64(value) => value.to_string(),
8032        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
8033        Value::Embedding(value) => format!("{value:?}"),
8034        Value::Decimal(value) => value.to_string(),
8035        Value::Interval {
8036            months,
8037            days,
8038            nanos,
8039        } => format!("{months}m {days}d {nanos}ns"),
8040        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
8041        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
8042    }
8043}
8044
8045fn validate_trigger_step<'a>(
8046    step: &TriggerStep,
8047    cat: &'a Catalog,
8048    target_schema: &Schema,
8049    event: TriggerEvent,
8050    select_schemas: &mut HashMap<String, &'a Schema>,
8051) -> Result<()> {
8052    match step {
8053        TriggerStep::SetNew { cells } => {
8054            if event == TriggerEvent::Delete {
8055                return Err(MongrelError::InvalidArgument(
8056                    "SetNew trigger step is not valid for DELETE triggers".into(),
8057                ));
8058            }
8059            for cell in cells {
8060                validate_column_id(cell.column_id, target_schema)?;
8061                validate_trigger_value(&cell.value, target_schema, event)?;
8062            }
8063        }
8064        TriggerStep::Insert { table, cells } => {
8065            let schema = trigger_write_schema(cat, table, "insert")?;
8066            for cell in cells {
8067                validate_column_id(cell.column_id, schema)?;
8068                validate_trigger_value(&cell.value, target_schema, event)?;
8069            }
8070        }
8071        TriggerStep::UpdateByPk { table, pk, cells } => {
8072            let schema = trigger_write_schema(cat, table, "update")?;
8073            if schema.primary_key().is_none() {
8074                return Err(MongrelError::InvalidArgument(format!(
8075                    "trigger update_by_pk references table {table:?} without a primary key"
8076                )));
8077            }
8078            validate_trigger_value(pk, target_schema, event)?;
8079            for cell in cells {
8080                validate_column_id(cell.column_id, schema)?;
8081                validate_trigger_value(&cell.value, target_schema, event)?;
8082            }
8083        }
8084        TriggerStep::DeleteByPk { table, pk } => {
8085            let schema = trigger_write_schema(cat, table, "delete")?;
8086            if schema.primary_key().is_none() {
8087                return Err(MongrelError::InvalidArgument(format!(
8088                    "trigger delete_by_pk references table {table:?} without a primary key"
8089                )));
8090            }
8091            validate_trigger_value(pk, target_schema, event)?;
8092        }
8093        TriggerStep::Select {
8094            id,
8095            table,
8096            conditions,
8097        } => {
8098            let schema = trigger_read_schema(cat, table)?;
8099            for condition in conditions {
8100                validate_trigger_condition(condition, schema, target_schema, event)?;
8101            }
8102            if select_schemas.contains_key(id) {
8103                return Err(MongrelError::InvalidArgument(format!(
8104                    "duplicate select id {id:?} in trigger program"
8105                )));
8106            }
8107            select_schemas.insert(id.clone(), schema);
8108        }
8109        TriggerStep::Foreach { id, steps } => {
8110            if !select_schemas.contains_key(id) {
8111                return Err(MongrelError::InvalidArgument(format!(
8112                    "foreach references unknown select id {id:?}"
8113                )));
8114            }
8115            let mut inner_select_schemas = select_schemas.clone();
8116            for step in steps {
8117                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
8118            }
8119        }
8120        TriggerStep::DeleteWhere { table, conditions } => {
8121            let schema = trigger_write_schema(cat, table, "delete")?;
8122            for condition in conditions {
8123                validate_trigger_condition(condition, schema, target_schema, event)?;
8124            }
8125        }
8126        TriggerStep::UpdateWhere {
8127            table,
8128            conditions,
8129            cells,
8130        } => {
8131            let schema = trigger_write_schema(cat, table, "update")?;
8132            for condition in conditions {
8133                validate_trigger_condition(condition, schema, target_schema, event)?;
8134            }
8135            for cell in cells {
8136                validate_column_id(cell.column_id, schema)?;
8137                validate_trigger_value(&cell.value, target_schema, event)?;
8138            }
8139        }
8140        TriggerStep::Raise { message, .. } => {
8141            validate_trigger_value(message, target_schema, event)?
8142        }
8143    }
8144    Ok(())
8145}
8146
8147fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
8148    if let Some(entry) = cat.live(table) {
8149        return Ok(&entry.schema);
8150    }
8151    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
8152        let allowed = match op {
8153            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
8154            "update" | "delete" => entry.capabilities.writable,
8155            _ => false,
8156        };
8157        if !allowed {
8158            return Err(MongrelError::InvalidArgument(format!(
8159                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
8160                entry.module
8161            )));
8162        }
8163        if !entry.capabilities.transaction_safe {
8164            return Err(MongrelError::InvalidArgument(format!(
8165                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
8166                entry.module
8167            )));
8168        }
8169        return Ok(&entry.declared_schema);
8170    }
8171    Err(MongrelError::InvalidArgument(format!(
8172        "trigger references unknown table {table:?}"
8173    )))
8174}
8175
8176fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
8177    if let Some(entry) = cat.live(table) {
8178        return Ok(&entry.schema);
8179    }
8180    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
8181        if entry.capabilities.trigger_safe {
8182            return Ok(&entry.declared_schema);
8183        }
8184        return Err(MongrelError::InvalidArgument(format!(
8185            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
8186            entry.module
8187        )));
8188    }
8189    Err(MongrelError::InvalidArgument(format!(
8190        "trigger references unknown table {table:?}"
8191    )))
8192}
8193
8194fn validate_trigger_condition(
8195    condition: &TriggerCondition,
8196    schema: &Schema,
8197    target_schema: &Schema,
8198    event: TriggerEvent,
8199) -> Result<()> {
8200    match condition {
8201        TriggerCondition::Pk { value } => {
8202            if schema.primary_key().is_none() {
8203                return Err(MongrelError::InvalidArgument(
8204                    "trigger condition Pk references a table without a primary key".into(),
8205                ));
8206            }
8207            validate_trigger_value(value, target_schema, event)
8208        }
8209        TriggerCondition::Eq { column_id, value }
8210        | TriggerCondition::NotEq { column_id, value }
8211        | TriggerCondition::Lt { column_id, value }
8212        | TriggerCondition::Lte { column_id, value }
8213        | TriggerCondition::Gt { column_id, value }
8214        | TriggerCondition::Gte { column_id, value } => {
8215            validate_column_id(*column_id, schema)?;
8216            validate_trigger_value(value, target_schema, event)
8217        }
8218        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
8219            validate_column_id(*column_id, schema)
8220        }
8221        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
8222            validate_trigger_condition(left, schema, target_schema, event)?;
8223            validate_trigger_condition(right, schema, target_schema, event)
8224        }
8225        TriggerCondition::Not(condition) => {
8226            validate_trigger_condition(condition, schema, target_schema, event)
8227        }
8228    }
8229}
8230
8231fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
8232    match expr {
8233        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
8234            validate_trigger_value(value, schema, event)
8235        }
8236        TriggerExpr::Eq { left, right }
8237        | TriggerExpr::NotEq { left, right }
8238        | TriggerExpr::Lt { left, right }
8239        | TriggerExpr::Lte { left, right }
8240        | TriggerExpr::Gt { left, right }
8241        | TriggerExpr::Gte { left, right } => {
8242            validate_trigger_value(left, schema, event)?;
8243            validate_trigger_value(right, schema, event)
8244        }
8245        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
8246            validate_trigger_expr(left, schema, event)?;
8247            validate_trigger_expr(right, schema, event)
8248        }
8249        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
8250    }
8251}
8252
8253fn validate_trigger_value(
8254    value: &TriggerValue,
8255    schema: &Schema,
8256    event: TriggerEvent,
8257) -> Result<()> {
8258    match value {
8259        TriggerValue::Literal(_) => Ok(()),
8260        TriggerValue::NewColumn(id) => {
8261            if event == TriggerEvent::Delete {
8262                return Err(MongrelError::InvalidArgument(
8263                    "DELETE triggers cannot reference NEW".into(),
8264                ));
8265            }
8266            validate_column_id(*id, schema)
8267        }
8268        TriggerValue::OldColumn(id) => {
8269            if event == TriggerEvent::Insert {
8270                return Err(MongrelError::InvalidArgument(
8271                    "INSERT triggers cannot reference OLD".into(),
8272                ));
8273            }
8274            validate_column_id(*id, schema)
8275        }
8276        // SELECTED column references are only meaningful inside a foreach loop.
8277        // Strict loop-scope validation is deferred to runtime; the executor raises
8278        // an error if a selected row is not available.
8279        TriggerValue::SelectedColumn(_) => Ok(()),
8280    }
8281}
8282
8283/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
8284/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
8285/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
8286/// catalog. This pass closes that window by reconstructing missing entries
8287/// (and marking committed drops) before tables are mounted.
8288fn recover_ddl_from_wal(
8289    root: &Path,
8290    cat: &mut Catalog,
8291    meta_dek: Option<&[u8; META_DEK_LEN]>,
8292    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
8293) -> Result<()> {
8294    use crate::wal::{DdlOp, Op, SharedWal};
8295
8296    let records = match SharedWal::replay_with_dek(root, wal_dek) {
8297        Ok(r) => r,
8298        Err(_) => return Ok(()),
8299    };
8300
8301    let mut committed: HashMap<u64, u64> = HashMap::new();
8302    for r in &records {
8303        if let Op::TxnCommit { epoch: ce, .. } = r.op {
8304            committed.insert(r.txn_id, ce);
8305        }
8306    }
8307
8308    let mut changed = false;
8309    for r in records {
8310        let Some(&ce) = committed.get(&r.txn_id) else {
8311            continue;
8312        };
8313        match r.op {
8314            Op::Ddl(DdlOp::CreateTable {
8315                table_id,
8316                ref name,
8317                ref schema_json,
8318            }) => {
8319                if cat.tables.iter().any(|t| t.table_id == table_id) {
8320                    continue;
8321                }
8322                let schema = DdlOp::decode_schema(schema_json)?;
8323                let tdir = root.join(TABLES_DIR).join(table_id.to_string());
8324                if !tdir.exists() {
8325                    std::fs::create_dir_all(tdir.join(crate::engine::WAL_DIR))?;
8326                    std::fs::create_dir_all(tdir.join(crate::engine::RUNS_DIR))?;
8327                    crate::engine::write_schema(&tdir, &schema)?;
8328                    // The DB-wide meta DEK is also the per-table manifest meta
8329                    // DEK (both derive from the KEK via `derive_meta_key`), so a
8330                    // reconstructed manifest must be sealed with it — otherwise
8331                    // the follow-up `Table::open_in` cannot authenticate it on an
8332                    // encrypted DB and the table becomes permanently unopenable.
8333                    let mut m = crate::manifest::Manifest::new(table_id, schema.schema_id);
8334                    crate::manifest::write_atomic(&tdir, &mut m, meta_dek)?;
8335                }
8336                cat.tables.push(CatalogEntry {
8337                    table_id,
8338                    name: name.clone(),
8339                    schema,
8340                    state: TableState::Live,
8341                    created_epoch: ce,
8342                });
8343                cat.next_table_id = cat.next_table_id.max(table_id + 1);
8344                changed = true;
8345            }
8346            Op::Ddl(DdlOp::DropTable { table_id }) => {
8347                let mut dropped_name = None;
8348                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
8349                    dropped_name = Some(entry.name.clone());
8350                    if matches!(entry.state, TableState::Live) {
8351                        entry.state = TableState::Dropped { at_epoch: ce };
8352                        changed = true;
8353                    }
8354                }
8355                if let Some(name) = dropped_name {
8356                    let before = cat.materialized_views.len();
8357                    cat.materialized_views
8358                        .retain(|definition| definition.name != name);
8359                    changed |= before != cat.materialized_views.len();
8360                    cat.security.rls_tables.retain(|table| table != &name);
8361                    cat.security.policies.retain(|policy| policy.table != name);
8362                    cat.security.masks.retain(|mask| mask.table != name);
8363                    for role in &mut cat.roles {
8364                        role.permissions
8365                            .retain(|permission| permission_table(permission) != Some(&name));
8366                    }
8367                    cat.security_version = cat.security_version.wrapping_add(1);
8368                }
8369            }
8370            Op::Ddl(DdlOp::RenameTable {
8371                table_id,
8372                ref new_name,
8373            }) => {
8374                let mut old_name = None;
8375                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
8376                    if entry.name != *new_name {
8377                        old_name = Some(entry.name.clone());
8378                        entry.name = new_name.clone();
8379                        changed = true;
8380                    }
8381                }
8382                if let Some(old_name) = old_name {
8383                    if let Some(definition) = cat
8384                        .materialized_views
8385                        .iter_mut()
8386                        .find(|definition| definition.name == old_name)
8387                    {
8388                        definition.name = new_name.clone();
8389                    }
8390                    for table in &mut cat.security.rls_tables {
8391                        if *table == old_name {
8392                            *table = new_name.clone();
8393                        }
8394                    }
8395                    for policy in &mut cat.security.policies {
8396                        if policy.table == old_name {
8397                            policy.table = new_name.clone();
8398                        }
8399                    }
8400                    for mask in &mut cat.security.masks {
8401                        if mask.table == old_name {
8402                            mask.table = new_name.clone();
8403                        }
8404                    }
8405                    for role in &mut cat.roles {
8406                        for permission in &mut role.permissions {
8407                            rename_permission_table(permission, &old_name, new_name);
8408                        }
8409                    }
8410                    cat.security_version = cat.security_version.wrapping_add(1);
8411                }
8412                // If the entry is absent, its CreateTable was already
8413                // checkpointed carrying the post-rename name, so there is
8414                // nothing to apply — a no-op, not an error.
8415            }
8416            Op::Ddl(DdlOp::AlterTable {
8417                table_id,
8418                ref column_json,
8419            }) => {
8420                let column = DdlOp::decode_column(column_json)?;
8421                let mut renamed = None;
8422                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
8423                    renamed = entry
8424                        .schema
8425                        .columns
8426                        .iter()
8427                        .find(|existing| existing.id == column.id && existing.name != column.name)
8428                        .map(|existing| {
8429                            (
8430                                entry.name.clone(),
8431                                existing.name.clone(),
8432                                column.name.clone(),
8433                            )
8434                        });
8435                    if apply_recovered_column_def(&mut entry.schema, column) {
8436                        let tdir = root.join(TABLES_DIR).join(table_id.to_string());
8437                        if tdir.exists() {
8438                            crate::engine::write_schema(&tdir, &entry.schema)?;
8439                        }
8440                        changed = true;
8441                    }
8442                }
8443                if let Some((table, old_name, new_name)) = renamed {
8444                    for role in &mut cat.roles {
8445                        for permission in &mut role.permissions {
8446                            rename_permission_column(permission, &table, &old_name, &new_name);
8447                        }
8448                    }
8449                    cat.security_version = cat.security_version.wrapping_add(1);
8450                }
8451            }
8452            Op::Ddl(DdlOp::SetTtl {
8453                table_id,
8454                ref policy_json,
8455            }) => {
8456                let policy = DdlOp::decode_ttl(policy_json)?;
8457                if let Some(policy) = policy {
8458                    let valid = cat
8459                        .tables
8460                        .iter()
8461                        .find(|entry| entry.table_id == table_id)
8462                        .and_then(|entry| {
8463                            entry
8464                                .schema
8465                                .columns
8466                                .iter()
8467                                .find(|column| column.id == policy.column_id)
8468                        })
8469                        .is_some_and(|column| {
8470                            column.ty == TypeId::TimestampNanos
8471                                && policy.duration_nanos > 0
8472                                && policy.duration_nanos <= i64::MAX as u64
8473                        });
8474                    if !valid {
8475                        return Err(MongrelError::Schema(format!(
8476                            "invalid recovered TTL policy for table id {table_id}"
8477                        )));
8478                    }
8479                }
8480                let tdir = root.join(TABLES_DIR).join(table_id.to_string());
8481                if tdir.exists() {
8482                    let mut manifest = crate::manifest::read(&tdir, meta_dek)?;
8483                    if manifest.ttl != policy || manifest.current_epoch < ce {
8484                        manifest.ttl = policy;
8485                        manifest.current_epoch = manifest.current_epoch.max(ce);
8486                        crate::manifest::write_atomic(&tdir, &mut manifest, meta_dek)?;
8487                    }
8488                }
8489            }
8490            Op::Ddl(DdlOp::SetMaterializedView {
8491                ref name,
8492                ref definition_json,
8493            }) => {
8494                let definition = DdlOp::decode_materialized_view(definition_json)?;
8495                if definition.name != *name {
8496                    return Err(MongrelError::Schema(format!(
8497                        "materialized view WAL name mismatch: {name:?}"
8498                    )));
8499                }
8500                if cat.live(name).is_some() {
8501                    if let Some(existing) = cat
8502                        .materialized_views
8503                        .iter_mut()
8504                        .find(|existing| existing.name == *name)
8505                    {
8506                        if *existing != definition {
8507                            *existing = definition;
8508                            changed = true;
8509                        }
8510                    } else {
8511                        cat.materialized_views.push(definition);
8512                        changed = true;
8513                    }
8514                }
8515            }
8516            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
8517                let security = DdlOp::decode_security(security_json)?;
8518                validate_security_catalog(cat, &security)?;
8519                if cat.security != security {
8520                    cat.security = security;
8521                    cat.security_version = cat.security_version.wrapping_add(1);
8522                    changed = true;
8523                }
8524            }
8525            _ => {}
8526        }
8527    }
8528
8529    if changed {
8530        catalog::write_atomic(root, cat, meta_dek)?;
8531    }
8532    Ok(())
8533}
8534
8535fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> bool {
8536    match schema.columns.iter_mut().find(|c| c.id == column.id) {
8537        Some(existing) if *existing == column => false,
8538        Some(existing) => {
8539            *existing = column;
8540            schema.schema_id = schema.schema_id.saturating_add(1);
8541            true
8542        }
8543        None => {
8544            schema.columns.push(column);
8545            schema.schema_id = schema.schema_id.saturating_add(1);
8546            true
8547        }
8548    }
8549}
8550
8551fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
8552    use crate::auth::Permission;
8553    match permission {
8554        Permission::Select { table }
8555        | Permission::Insert { table }
8556        | Permission::Update { table }
8557        | Permission::Delete { table }
8558        | Permission::SelectColumns { table, .. }
8559        | Permission::InsertColumns { table, .. }
8560        | Permission::UpdateColumns { table, .. } => Some(table),
8561        Permission::All | Permission::Ddl | Permission::Admin => None,
8562    }
8563}
8564
8565fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
8566    use crate::auth::Permission;
8567    let table = match permission {
8568        Permission::Select { table }
8569        | Permission::Insert { table }
8570        | Permission::Update { table }
8571        | Permission::Delete { table }
8572        | Permission::SelectColumns { table, .. }
8573        | Permission::InsertColumns { table, .. }
8574        | Permission::UpdateColumns { table, .. } => Some(table),
8575        Permission::All | Permission::Ddl | Permission::Admin => None,
8576    };
8577    if let Some(table) = table.filter(|table| table.as_str() == old) {
8578        *table = new.to_string();
8579    }
8580}
8581
8582fn rename_permission_column(
8583    permission: &mut crate::auth::Permission,
8584    target_table: &str,
8585    old: &str,
8586    new: &str,
8587) {
8588    use crate::auth::Permission;
8589    let columns = match permission {
8590        Permission::SelectColumns { table, columns }
8591        | Permission::InsertColumns { table, columns }
8592        | Permission::UpdateColumns { table, columns }
8593            if table == target_table =>
8594        {
8595            Some(columns)
8596        }
8597        _ => None,
8598    };
8599    if let Some(column) = columns
8600        .into_iter()
8601        .flatten()
8602        .find(|column| column.as_str() == old)
8603    {
8604        *column = new.to_string();
8605    }
8606}
8607
8608fn merge_permission(
8609    permissions: &mut Vec<crate::auth::Permission>,
8610    permission: crate::auth::Permission,
8611) {
8612    use crate::auth::Permission;
8613    let (kind, table, mut columns) = match permission {
8614        Permission::SelectColumns { table, columns } => (0, table, columns),
8615        Permission::InsertColumns { table, columns } => (1, table, columns),
8616        Permission::UpdateColumns { table, columns } => (2, table, columns),
8617        permission if !permissions.contains(&permission) => {
8618            permissions.push(permission);
8619            return;
8620        }
8621        _ => return,
8622    };
8623    for permission in permissions.iter_mut() {
8624        let existing = match permission {
8625            Permission::SelectColumns {
8626                table: existing_table,
8627                columns,
8628            } if kind == 0 && existing_table == &table => Some(columns),
8629            Permission::InsertColumns {
8630                table: existing_table,
8631                columns,
8632            } if kind == 1 && existing_table == &table => Some(columns),
8633            Permission::UpdateColumns {
8634                table: existing_table,
8635                columns,
8636            } if kind == 2 && existing_table == &table => Some(columns),
8637            _ => None,
8638        };
8639        if let Some(existing) = existing {
8640            existing.append(&mut columns);
8641            existing.sort();
8642            existing.dedup();
8643            return;
8644        }
8645    }
8646    columns.sort();
8647    columns.dedup();
8648    permissions.push(match kind {
8649        0 => Permission::SelectColumns { table, columns },
8650        1 => Permission::InsertColumns { table, columns },
8651        2 => Permission::UpdateColumns { table, columns },
8652        _ => unreachable!(),
8653    });
8654}
8655
8656fn revoke_permission_from(
8657    permissions: &mut Vec<crate::auth::Permission>,
8658    revoked: &crate::auth::Permission,
8659) {
8660    use crate::auth::Permission;
8661    let revoked_columns = match revoked {
8662        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
8663        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
8664        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
8665        _ => None,
8666    };
8667    let Some((kind, table, columns)) = revoked_columns else {
8668        permissions.retain(|permission| permission != revoked);
8669        return;
8670    };
8671    for permission in permissions.iter_mut() {
8672        let current = match permission {
8673            Permission::SelectColumns {
8674                table: current_table,
8675                columns,
8676            } if kind == 0 && current_table == table => Some(columns),
8677            Permission::InsertColumns {
8678                table: current_table,
8679                columns,
8680            } if kind == 1 && current_table == table => Some(columns),
8681            Permission::UpdateColumns {
8682                table: current_table,
8683                columns,
8684            } if kind == 2 && current_table == table => Some(columns),
8685            _ => None,
8686        };
8687        if let Some(current) = current {
8688            current.retain(|column| !columns.contains(column));
8689        }
8690    }
8691    permissions.retain(|permission| match permission {
8692        Permission::SelectColumns { columns, .. }
8693        | Permission::InsertColumns { columns, .. }
8694        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
8695        _ => true,
8696    });
8697}
8698
8699fn validate_security_catalog(
8700    catalog: &Catalog,
8701    security: &crate::security::SecurityCatalog,
8702) -> Result<()> {
8703    let mut policy_names = HashSet::new();
8704    for table in &security.rls_tables {
8705        if catalog.live(table).is_none() {
8706            return Err(MongrelError::NotFound(format!(
8707                "RLS table {table:?} not found"
8708            )));
8709        }
8710    }
8711    for policy in &security.policies {
8712        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
8713            return Err(MongrelError::InvalidArgument(format!(
8714                "duplicate policy {:?} on {:?}",
8715                policy.name, policy.table
8716            )));
8717        }
8718        let schema = &catalog
8719            .live(&policy.table)
8720            .ok_or_else(|| {
8721                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
8722            })?
8723            .schema;
8724        if let Some(expression) = &policy.using {
8725            validate_security_expression(expression, schema)?;
8726        }
8727        if let Some(expression) = &policy.with_check {
8728            validate_security_expression(expression, schema)?;
8729        }
8730    }
8731    let mut mask_names = HashSet::new();
8732    for mask in &security.masks {
8733        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
8734            return Err(MongrelError::InvalidArgument(format!(
8735                "duplicate mask {:?} on {:?}",
8736                mask.name, mask.table
8737            )));
8738        }
8739        let column = catalog
8740            .live(&mask.table)
8741            .and_then(|entry| {
8742                entry
8743                    .schema
8744                    .columns
8745                    .iter()
8746                    .find(|column| column.id == mask.column)
8747            })
8748            .ok_or_else(|| {
8749                MongrelError::NotFound(format!(
8750                    "mask column {} on {:?} not found",
8751                    mask.column, mask.table
8752                ))
8753            })?;
8754        if matches!(
8755            mask.strategy,
8756            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
8757        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
8758        {
8759            return Err(MongrelError::InvalidArgument(format!(
8760                "mask {:?} requires a string/bytes column",
8761                mask.name
8762            )));
8763        }
8764    }
8765    Ok(())
8766}
8767
8768fn validate_security_expression(
8769    expression: &crate::security::SecurityExpr,
8770    schema: &Schema,
8771) -> Result<()> {
8772    use crate::security::SecurityExpr;
8773    match expression {
8774        SecurityExpr::True => Ok(()),
8775        SecurityExpr::ColumnEqCurrentUser { column }
8776        | SecurityExpr::ColumnEqValue { column, .. } => {
8777            if schema
8778                .columns
8779                .iter()
8780                .any(|candidate| candidate.id == *column)
8781            {
8782                Ok(())
8783            } else {
8784                Err(MongrelError::InvalidArgument(format!(
8785                    "security expression references unknown column id {column}"
8786                )))
8787            }
8788        }
8789        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
8790            validate_security_expression(left, schema)?;
8791            validate_security_expression(right, schema)
8792        }
8793        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
8794    }
8795}
8796
8797/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
8798/// #14). These dirs hold pending uniform-epoch runs from large transactions
8799/// that were aborted or crashed before commit. On open, all such dirs are safe
8800/// to remove — committed txns moved their runs to `_runs/` at publish time.
8801fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
8802    for entry in &cat.tables {
8803        let txn_dir = root
8804            .join(TABLES_DIR)
8805            .join(entry.table_id.to_string())
8806            .join("_txn");
8807        if txn_dir.exists() {
8808            let _ = std::fs::remove_dir_all(&txn_dir);
8809        }
8810    }
8811}
8812
8813#[cfg(test)]
8814mod trigger_engine_tests {
8815    use super::*;
8816
8817    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
8818        WriteEvent {
8819            table: "test".into(),
8820            kind: TriggerEvent::Insert,
8821            new: Some(TriggerRowImage {
8822                columns: new_cells.iter().cloned().collect(),
8823            }),
8824            old: Some(TriggerRowImage {
8825                columns: old_cells.iter().cloned().collect(),
8826            }),
8827            changed_columns: Vec::new(),
8828            op_indices: Vec::new(),
8829            put_idx: None,
8830            trigger_stack: Vec::new(),
8831        }
8832    }
8833
8834    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
8835        WriteEvent {
8836            table: "test".into(),
8837            kind: TriggerEvent::Insert,
8838            new: Some(TriggerRowImage {
8839                columns: new_cells.iter().cloned().collect(),
8840            }),
8841            old: None,
8842            changed_columns: Vec::new(),
8843            op_indices: Vec::new(),
8844            put_idx: None,
8845            trigger_stack: Vec::new(),
8846        }
8847    }
8848
8849    #[test]
8850    fn value_order_int64_vs_float64() {
8851        assert_eq!(
8852            value_order(&Value::Int64(5), &Value::Float64(5.0)),
8853            Some(std::cmp::Ordering::Equal)
8854        );
8855        assert_eq!(
8856            value_order(&Value::Int64(5), &Value::Float64(3.0)),
8857            Some(std::cmp::Ordering::Greater)
8858        );
8859        assert_eq!(
8860            value_order(&Value::Int64(2), &Value::Float64(3.0)),
8861            Some(std::cmp::Ordering::Less)
8862        );
8863    }
8864
8865    #[test]
8866    fn value_order_null_returns_none() {
8867        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
8868        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
8869        assert_eq!(value_order(&Value::Null, &Value::Null), None);
8870    }
8871
8872    #[test]
8873    fn value_order_cross_group_returns_none() {
8874        assert_eq!(
8875            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
8876            None
8877        );
8878        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
8879        assert_eq!(
8880            value_order(
8881                &Value::Embedding(vec![1.0, 2.0]),
8882                &Value::Embedding(vec![1.0, 2.0])
8883            ),
8884            None
8885        );
8886    }
8887
8888    #[test]
8889    fn eval_trigger_expr_ranges_and_booleans() {
8890        let expr = TriggerExpr::And {
8891            left: Box::new(TriggerExpr::Gt {
8892                left: TriggerValue::NewColumn(1),
8893                right: TriggerValue::Literal(Value::Int64(0)),
8894            }),
8895            right: Box::new(TriggerExpr::Lte {
8896                left: TriggerValue::NewColumn(1),
8897                right: TriggerValue::Literal(Value::Int64(100)),
8898            }),
8899        };
8900        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
8901        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
8902        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
8903
8904        let or_expr = TriggerExpr::Or {
8905            left: Box::new(TriggerExpr::Lt {
8906                left: TriggerValue::NewColumn(1),
8907                right: TriggerValue::Literal(Value::Int64(0)),
8908            }),
8909            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
8910                TriggerValue::OldColumn(2),
8911            )))),
8912        };
8913        assert!(eval_trigger_expr(
8914            &or_expr,
8915            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
8916        )
8917        .unwrap());
8918        assert!(!eval_trigger_expr(
8919            &or_expr,
8920            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
8921        )
8922        .unwrap());
8923
8924        assert!(eval_trigger_expr(
8925            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
8926            &event_insert(&[])
8927        )
8928        .unwrap());
8929        assert!(!eval_trigger_expr(
8930            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
8931            &event_insert(&[])
8932        )
8933        .unwrap());
8934        assert!(!eval_trigger_expr(
8935            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
8936            &event_insert(&[])
8937        )
8938        .unwrap());
8939    }
8940}