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