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