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