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