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};
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;
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, AtomicU32};
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";
37
38/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
39/// than any table. `u64::MAX` is never allocated to a real table (the catalog
40/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
41pub const WAL_TABLE_ID: u64 = u64::MAX;
42/// Sentinel `table_id` for `CheckIssue`s that concern external-table module
43/// state instead of an ordinary table.
44pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
45
46#[derive(Debug, Clone)]
47pub enum ExternalTriggerWrite {
48    Insert {
49        table: String,
50        cells: Vec<(u16, Value)>,
51    },
52    UpdateByPk {
53        table: String,
54        pk: Value,
55        cells: Vec<(u16, Value)>,
56    },
57    DeleteByPk {
58        table: String,
59        pk: Value,
60    },
61}
62
63impl ExternalTriggerWrite {
64    fn table(&self) -> &str {
65        match self {
66            Self::Insert { table, .. }
67            | Self::UpdateByPk { table, .. }
68            | Self::DeleteByPk { table, .. } => table,
69        }
70    }
71}
72
73#[derive(Debug, Clone, PartialEq)]
74pub enum ExternalTriggerBaseWrite {
75    Put {
76        table: String,
77        cells: Vec<(u16, Value)>,
78    },
79    Delete {
80        table: String,
81        row_id: RowId,
82    },
83}
84
85#[derive(Debug, Clone, PartialEq)]
86pub struct ExternalTriggerWriteResult {
87    pub state: Vec<u8>,
88    pub base_writes: Vec<ExternalTriggerBaseWrite>,
89}
90
91impl ExternalTriggerWriteResult {
92    pub fn new(state: Vec<u8>) -> Self {
93        Self {
94            state,
95            base_writes: Vec::new(),
96        }
97    }
98}
99
100pub trait ExternalTriggerBridge {
101    fn apply_trigger_external_write(
102        &self,
103        entry: &ExternalTableEntry,
104        base_state: Vec<u8>,
105        op: ExternalTriggerWrite,
106    ) -> Result<ExternalTriggerWriteResult>;
107}
108
109/// A pending uniform-epoch run written during a large transaction (spec §8.5).
110struct SpilledRun {
111    table_id: u64,
112    run_id: u128,
113    pending_path: PathBuf,
114    rows: Vec<crate::memtable::Row>,
115    row_count: u64,
116    min_rid: u64,
117    max_rid: u64,
118}
119
120#[derive(Debug, Clone)]
121struct TriggerRowImage {
122    columns: HashMap<u16, Value>,
123}
124
125impl TriggerRowImage {
126    fn from_row(row: crate::memtable::Row) -> Self {
127        Self {
128            columns: row.columns,
129        }
130    }
131
132    fn from_cells(cells: &[(u16, Value)]) -> Self {
133        Self {
134            columns: cells.iter().cloned().collect(),
135        }
136    }
137}
138
139#[derive(Debug, Clone)]
140struct WriteEvent {
141    table: String,
142    kind: TriggerEvent,
143    old: Option<TriggerRowImage>,
144    new: Option<TriggerRowImage>,
145    changed_columns: Vec<u16>,
146    op_indices: Vec<usize>,
147    put_idx: Option<usize>,
148    trigger_stack: Vec<String>,
149}
150
151#[derive(Default)]
152struct TriggerExpansion {
153    before: Vec<(u64, crate::txn::Staged)>,
154    before_stacks: Vec<Vec<String>>,
155    before_external: Vec<ExternalTriggerWrite>,
156    after: Vec<(u64, crate::txn::Staged)>,
157    after_stacks: Vec<Vec<String>>,
158    after_external: Vec<ExternalTriggerWrite>,
159    ignored_indices: std::collections::BTreeSet<usize>,
160}
161
162struct TriggerProgramOutput<'a> {
163    added: &'a mut Vec<(u64, crate::txn::Staged)>,
164    added_stacks: &'a mut Vec<Vec<String>>,
165    added_external: &'a mut Vec<ExternalTriggerWrite>,
166    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170enum TriggerProgramOutcome {
171    Continue,
172    Ignore,
173}
174
175/// An integrity issue found by [`Database::check`] (spec §16).
176#[derive(Debug, Clone)]
177pub struct CheckIssue {
178    pub table_id: u64,
179    pub table_name: String,
180    pub severity: String,
181    pub description: String,
182}
183
184/// A handle to a live table inside a [`Database`]. Writes take the inner lock
185/// (P1); P3.3 replaces this with lock-free `ArcSwap` reads + a publish lock for
186/// writes.
187pub type TableHandle = Arc<Mutex<Table>>;
188
189/// A multi-table database: one catalog, one epoch clock, shared caches, a
190/// shared WAL, and a live map of name → `Arc<Table>`.
191pub struct Database {
192    root: PathBuf,
193    catalog: RwLock<Catalog>,
194    epoch: Arc<EpochAuthority>,
195    snapshots: Arc<SnapshotRegistry>,
196    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
197    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
198    commit_lock: Arc<Mutex<()>>,
199    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
200    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
201    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
202    /// writes also land in this one WAL (B1 — one WAL per database).
203    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
204    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
205    /// in P2.7; here it just needs to be unique within an open. Shared with
206    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
207    next_txn_id: Arc<Mutex<u64>>,
208    tables: RwLock<HashMap<u64, TableHandle>>,
209    kek: Option<Arc<crate::encryption::Kek>>,
210    /// Serializes DDL (create/drop table); data commits serialize through
211    /// `commit_lock` shared via `SharedCtx`.
212    ddl_lock: Mutex<()>,
213    meta_dek: Option<[u8; META_DEK_LEN]>,
214    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
215    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
216    spill_threshold: std::sync::atomic::AtomicU64,
217    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
218    /// detection (spec §9.2).
219    conflicts: crate::txn::ConflictIndex,
220    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
221    /// pruning (spec §9.2, review fix #12).
222    active_txns: crate::txn::ActiveTxns,
223    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
224    /// Shared with mounted tables so a single-table commit also honors poison.
225    poisoned: Arc<std::sync::atomic::AtomicBool>,
226    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
227    /// but defers the fsync to one leader here, so concurrent commits share a
228    /// single fsync (spec §9.3). Shared with mounted tables.
229    group: Arc<crate::txn::GroupCommit>,
230    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
231    /// live spill's pending dir (review fix #14, spec §6.4).
232    active_spills: Arc<crate::retention::ActiveSpills>,
233    /// Test-only barrier invoked after a transaction writes its spill runs but
234    /// before the sequencer/publish, so tests can race `gc()` against an
235    /// in-flight spill. `None` in production.
236    #[doc(hidden)]
237    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
238    trigger_recursive: AtomicBool,
239    trigger_max_depth: AtomicU32,
240    trigger_max_loop_iterations: AtomicU32,
241    /// Exclusive cross-process lock held for the database's lifetime to prevent
242    /// two processes from opening the same directory concurrently.
243    _lock: Option<std::fs::File>,
244    /// Lightweight notification channel for CDC / NOTIFY-LISTEN. Each committed
245    /// transaction that produces `Put`/`Delete` ops publishes a `ChangeEvent`
246    /// here. Subscribers (daemon `/events` endpoint, application listeners)
247    /// receive them asynchronously.
248    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
249    /// The authenticated principal for this handle. `None` on databases
250    /// opened without credentials (the default — `require_auth = false`),
251    /// `Some` on credentialed opens. Consulted by every enforcement point
252    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
253    /// because the access pattern is read-heavy: every `require()` call
254    /// reads the principal, while writes happen only at open, `enable_auth`,
255    /// and `refresh_principal`. This matches the engine's existing use of
256    /// `RwLock` for `catalog` and `tables`.
257    /// See `docs/auth-enforcement-spec.md`.
258    principal: RwLock<Option<crate::auth::Principal>>,
259}
260
261/// A data-change event published on commit (CDC / NOTIFY-LISTEN).
262#[derive(Debug, Clone, serde::Serialize)]
263pub struct ChangeEvent {
264    pub channel: String,
265    pub table: String,
266    pub op: String,
267    pub epoch: u64,
268    pub message: Option<String>,
269}
270
271/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
272/// (root, epoch, table count, encryption/auth state) without requiring every
273/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
274/// The raw field types carry locks, trait objects, and channels that have no
275/// useful `Debug` output, so a hand-written impl is clearer than peppering
276/// `#[allow(dead_code)]` skip attributes across two dozen fields.
277impl std::fmt::Debug for Database {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        let cat = self.catalog.read();
280        let principal_guard = self.principal.read();
281        let principal: &str = principal_guard
282            .as_ref()
283            .map(|p| p.username.as_str())
284            .unwrap_or("<none>");
285        f.debug_struct("Database")
286            .field("root", &self.root)
287            .field("db_epoch", &cat.db_epoch)
288            .field("open_generation", &cat.open_generation)
289            .field("tables", &cat.tables.len())
290            .field("visible_epoch", &self.epoch.visible().0)
291            .field("encrypted", &self.kek.is_some())
292            .field("require_auth", &cat.require_auth)
293            .field("principal", &principal)
294            .finish()
295    }
296}
297
298impl Database {
299    /// Create a fresh plaintext database at `root`.
300    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
301        Self::create_inner(root, None)
302    }
303
304    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
305    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
306    #[cfg(feature = "encryption")]
307    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
308        let root = root.as_ref();
309        std::fs::create_dir_all(root)?;
310        std::fs::create_dir_all(root.join(META_DIR))?;
311        let salt = crate::encryption::random_salt();
312        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
313        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
314        Self::create_inner(root, Some(kek))
315    }
316
317    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
318    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
319    #[cfg(feature = "encryption")]
320    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
321        let root = root.as_ref();
322        std::fs::create_dir_all(root)?;
323        std::fs::create_dir_all(root.join(META_DIR))?;
324        let salt = crate::encryption::random_salt();
325        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
326        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
327        Self::create_inner(root, Some(kek))
328    }
329
330    fn create_inner(
331        root: impl AsRef<Path>,
332        kek: Option<Arc<crate::encryption::Kek>>,
333    ) -> Result<Self> {
334        let root = root.as_ref().to_path_buf();
335        std::fs::create_dir_all(&root)?;
336        std::fs::create_dir_all(root.join(TABLES_DIR))?;
337        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
338        let cat = Catalog::empty();
339        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
340        Self::finish_open(root, cat, kek, meta_dek, false, None)
341    }
342
343    /// Open an existing plaintext database.
344    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
345        Self::open_inner(root, None, None)
346    }
347
348    /// Open an existing encrypted database with a passphrase.
349    #[cfg(feature = "encryption")]
350    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
351        let root = root.as_ref();
352        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
353            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
354        let mut salt = [0u8; crate::encryption::SALT_LEN];
355        salt.copy_from_slice(&salt_bytes);
356        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
357        Self::open_inner(root, Some(kek), None)
358    }
359
360    /// Open an existing encrypted database using a raw high-entropy key.
361    #[cfg(feature = "encryption")]
362    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
363        let root = root.as_ref();
364        let salt_path = root.join(META_DIR).join(KEYS_FILENAME);
365        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
366            MongrelError::NotFound(format!(
367                "encryption salt file {:?}: {e} (database not encrypted, or corrupted)",
368                salt_path
369            ))
370        })?;
371        if salt_bytes.len() != crate::encryption::SALT_LEN {
372            return Err(MongrelError::InvalidArgument(format!(
373                "salt file is {} bytes, expected {}",
374                salt_bytes.len(),
375                crate::encryption::SALT_LEN
376            )));
377        }
378        let mut salt = [0u8; crate::encryption::SALT_LEN];
379        salt.copy_from_slice(&salt_bytes);
380        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
381        Self::open_inner(root, Some(kek), None)
382    }
383
384    /// Open an existing plaintext database that has `require_auth = true`,
385    /// verifying the supplied credentials up front and caching the resolved
386    /// [`Principal`] on the returned handle. Every subsequent operation will
387    /// be checked against that principal.
388    ///
389    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
390    /// `require_auth` enabled — callers must pick the matching constructor for
391    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
392    /// bad username/password.
393    ///
394    /// See `docs/auth-enforcement-spec.md`.
395    pub fn open_with_credentials(
396        root: impl AsRef<Path>,
397        username: &str,
398        password: &str,
399    ) -> Result<Self> {
400        Self::open_inner_with_credentials(root, None, username, password)
401    }
402
403    /// Open an existing encrypted database that has `require_auth = true`,
404    /// combining the encryption passphrase flow with credential verification.
405    #[cfg(feature = "encryption")]
406    pub fn open_encrypted_with_credentials(
407        root: impl AsRef<Path>,
408        passphrase: &str,
409        username: &str,
410        password: &str,
411    ) -> Result<Self> {
412        let root = root.as_ref();
413        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
414            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
415        let mut salt = [0u8; crate::encryption::SALT_LEN];
416        salt.copy_from_slice(&salt_bytes);
417        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
418        Self::open_inner_with_credentials(root, Some(kek), username, password)
419    }
420
421    /// Shared credentialed-open inner: read the catalog, verify the database
422    /// requires auth, verify the password, resolve the principal, and pass
423    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
424    /// problem where `finish_open`'s fail-closed check (`require_auth &&
425    /// principal.is_none()`) would fire before a post-open `authenticate()`
426    /// could supply the principal.
427    fn open_inner_with_credentials(
428        root: impl AsRef<Path>,
429        kek: Option<Arc<crate::encryption::Kek>>,
430        username: &str,
431        password: &str,
432    ) -> Result<Self> {
433        let root = root.as_ref().to_path_buf();
434        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
435        let cat = catalog::read(&root, meta_dek.as_ref())?
436            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
437
438        // Fail early if the database is not in require_auth mode — the caller
439        // picked the wrong constructor.
440        if !cat.require_auth {
441            return Err(MongrelError::AuthNotRequired);
442        }
443
444        // Verify credentials against the on-disk catalog before constructing
445        // the full Database handle. This reads users/hashes directly from the
446        // loaded catalog rather than going through the Database::verify_user
447        // method (which requires a constructed Database).
448        let user = cat
449            .users
450            .iter()
451            .find(|u| u.username == username)
452            .filter(|u| !u.password_hash.is_empty())
453            .ok_or_else(|| MongrelError::InvalidCredentials {
454                username: username.to_string(),
455            })?;
456        let password_ok = crate::auth::verify_password(password, &user.password_hash)
457            .map_err(MongrelError::Other)?;
458        if !password_ok {
459            return Err(MongrelError::InvalidCredentials {
460                username: username.to_string(),
461            });
462        }
463
464        // Resolve the principal from the catalog (roles + permissions).
465        let principal =
466            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
467                MongrelError::InvalidCredentials {
468                    username: username.to_string(),
469                }
470            })?;
471
472        Self::finish_open(root, cat, kek, meta_dek, true, Some(principal))
473    }
474
475    /// Create a fresh plaintext database with `require_auth = true` and a
476    /// single admin user. The returned handle is already authenticated as
477    /// that admin — every subsequent operation is checked against the admin
478    /// principal (which bypasses all permission checks via `is_admin`).
479    ///
480    /// This is the bootstrap path: there is no window where the database
481    /// requires auth but has no users.
482    ///
483    /// See `docs/auth-enforcement-spec.md`.
484    pub fn create_with_credentials(
485        root: impl AsRef<Path>,
486        admin_username: &str,
487        admin_password: &str,
488    ) -> Result<Self> {
489        Self::create_inner_with_credentials(root, None, admin_username, admin_password)
490    }
491
492    /// Create a fresh encrypted database with `require_auth = true` and a
493    /// single admin user. Composes encryption-at-rest with credential
494    /// enforcement.
495    #[cfg(feature = "encryption")]
496    pub fn create_encrypted_with_credentials(
497        root: impl AsRef<Path>,
498        passphrase: &str,
499        admin_username: &str,
500        admin_password: &str,
501    ) -> Result<Self> {
502        let root = root.as_ref();
503        std::fs::create_dir_all(root)?;
504        std::fs::create_dir_all(root.join(META_DIR))?;
505        let salt = crate::encryption::random_salt();
506        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
507        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
508        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password)
509    }
510
511    fn create_inner_with_credentials(
512        root: impl AsRef<Path>,
513        kek: Option<Arc<crate::encryption::Kek>>,
514        admin_username: &str,
515        admin_password: &str,
516    ) -> Result<Self> {
517        let root = root.as_ref().to_path_buf();
518        std::fs::create_dir_all(&root)?;
519        std::fs::create_dir_all(root.join(TABLES_DIR))?;
520        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
521
522        // Build the initial catalog with require_auth = true and one admin user.
523        let password_hash =
524            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
525        let mut cat = Catalog::empty();
526        cat.require_auth = true;
527        cat.next_user_id = 1;
528        cat.users.push(crate::auth::UserEntry {
529            id: 1,
530            username: admin_username.to_string(),
531            password_hash,
532            roles: Vec::new(),
533            is_admin: true,
534            created_epoch: 0,
535        });
536        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
537
538        // The handle is constructed already authenticated as the admin user
539        // it just created — no separate verify step needed.
540        let admin_principal = crate::auth::Principal {
541            username: admin_username.to_string(),
542            is_admin: true,
543            roles: Vec::new(),
544            permissions: Vec::new(),
545        };
546        Ok(Self::finish_open(
547            root,
548            cat,
549            kek,
550            meta_dek,
551            false,
552            Some(admin_principal),
553        )?)
554    }
555
556    fn open_inner(
557        root: impl AsRef<Path>,
558        kek: Option<Arc<crate::encryption::Kek>>,
559        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
560    ) -> Result<Self> {
561        let root = root.as_ref().to_path_buf();
562        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
563        let cat = catalog::read(&root, meta_dek.as_ref())?
564            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
565        Self::finish_open(root, cat, kek, meta_dek, true, None)
566    }
567
568    fn finish_open(
569        root: PathBuf,
570        cat: Catalog,
571        kek: Option<Arc<crate::encryption::Kek>>,
572        meta_dek: Option<[u8; META_DEK_LEN]>,
573        existing: bool,
574        principal: Option<crate::auth::Principal>,
575    ) -> Result<Self> {
576        // Acquire an exclusive cross-process lock on the database directory.
577        // This prevents two *processes* from opening the same DB simultaneously
578        // (which would corrupt data). Multiple opens within the *same* process
579        // are allowed (they share memory via Arc) — so we track locked paths in
580        // a process-global set and skip re-locking if already held.
581        std::fs::create_dir_all(root.join("_meta")).ok();
582        let lock_path = root.join("_meta").join(".lock");
583        let canonical = lock_path.canonicalize().unwrap_or(lock_path.clone());
584        let lock_file = {
585            static LOCKED_PATHS: std::sync::OnceLock<
586                std::sync::Mutex<std::collections::HashSet<PathBuf>>,
587            > = std::sync::OnceLock::new();
588            let locked = LOCKED_PATHS
589                .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
590            let mut guard = locked.lock().unwrap();
591            if guard.contains(&canonical) {
592                // Already locked by this process — allow the re-open.
593                None
594            } else {
595                let f = std::fs::OpenOptions::new()
596                    .create(true)
597                    .truncate(false)
598                    .write(true)
599                    .open(&lock_path)?;
600                use fs2::FileExt;
601                f.try_lock_exclusive().map_err(|e| {
602                    MongrelError::Io(std::io::Error::other(format!(
603                        "database at {} is locked by another process: {e}",
604                        root.display()
605                    )))
606                })?;
607                guard.insert(canonical.clone());
608                Some(f)
609            }
610        };
611
612        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
613        let snapshots = Arc::new(SnapshotRegistry::new());
614        let page_cache = Arc::new(crate::cache::Sharded::new(
615            crate::cache::CACHE_SHARDS,
616            || {
617                crate::cache::PageCache::new(
618                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
619                )
620            },
621        ));
622        let decoded_cache = Arc::new(crate::cache::Sharded::new(
623            crate::cache::CACHE_SHARDS,
624            || {
625                crate::cache::DecodedPageCache::new(
626                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
627                )
628            },
629        ));
630        let commit_lock = Arc::new(Mutex::new(()));
631        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
632        let shared_wal = Arc::new(Mutex::new(if existing {
633            crate::wal::SharedWal::open(&root, Epoch(cat.db_epoch), wal_dek.clone())?
634        } else {
635            crate::wal::SharedWal::create_with_dek(&root, Epoch(cat.db_epoch), wal_dek.clone())?
636        }));
637        // Shared write-path state handed to every mounted table so single-table
638        // `put`/`commit` writes route through the one shared WAL, the one group-
639        // commit coordinator, and the one poison flag (B1).
640        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
641        let group = Arc::new(crate::txn::GroupCommit::new(
642            shared_wal.lock().durable_seq(),
643        ));
644        // Final base value is set after the open-generation bump below; tables
645        // only draw ids once the user issues a write (post-open), so the
646        // placeholder is never observed.
647        let txn_ids = Arc::new(Mutex::new(1u64));
648
649        // Recover DDL from the shared WAL BEFORE opening tables (spec §15,
650        // review fix #16). A crash between WAL fsync and the catalog
651        // checkpoint leaves committed DDL durable in the WAL but absent from
652        // the on-disk catalog; replay it here so the table-mounting loop and
653        // data-record recovery see a correct catalog.
654        let mut cat = cat;
655        if existing {
656            recover_ddl_from_wal(&root, &mut cat, meta_dek.as_ref(), wal_dek.as_ref())?;
657        }
658
659        // Open every live table against the shared context. Mounted tables have
660        // no private WAL (B1) — `open_in` just loads the manifest/runs and
661        // advances the shared epoch authority to its manifest epoch, so the
662        // final shared watermark is the max across all tables. All of a mounted
663        // table's committed records are replayed below from the shared WAL.
664        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
665        for entry in &cat.tables {
666            if !matches!(entry.state, TableState::Live) {
667                continue;
668            }
669            let tdir = root.join(TABLES_DIR).join(entry.table_id.to_string());
670            let ctx = SharedCtx {
671                epoch: Arc::clone(&epoch),
672                page_cache: Arc::clone(&page_cache),
673                decoded_cache: Arc::clone(&decoded_cache),
674                snapshots: Arc::clone(&snapshots),
675                kek: kek.clone(),
676                commit_lock: Arc::clone(&commit_lock),
677                shared: Some(crate::engine::SharedWalCtx {
678                    wal: Arc::clone(&shared_wal),
679                    group: Arc::clone(&group),
680                    poisoned: Arc::clone(&poisoned),
681                    txn_ids: Arc::clone(&txn_ids),
682                }),
683            };
684            let t = Table::open_in(&tdir, ctx)?;
685            tables.insert(entry.table_id, Arc::new(Mutex::new(t)));
686        }
687
688        // Recover transaction writes from the shared WAL (spec §15). This is the
689        // single durability source for mounted tables: it applies every committed
690        // record — both single-table `Table::commit` writes and cross-table
691        // transactions — gated by each table's `flushed_epoch` (records already
692        // durable in a run are not re-applied).
693        if existing {
694            recover_shared_wal(&root, &tables, &epoch, wal_dek.as_ref())?;
695            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
696            // large transactions (spec §8.5, review fix #14).
697            sweep_pending_txn_dirs(&root, &cat);
698        }
699
700        // Bump `open_generation` on every open and scope transaction ids by it
701        // (`txn_id = (generation << 32) | counter`), so ids never alias across
702        // reopens (review fix #11). Persist the bumped generation to the catalog.
703        if existing {
704            cat.open_generation = cat.open_generation.wrapping_add(1);
705            catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
706        }
707        let next_txn_id = (cat.open_generation << 32) | 1;
708        // Seed the shared txn-id allocator now that the generation is final.
709        *txn_ids.lock() = next_txn_id;
710
711        // Fail-closed: an existing database with `require_auth = true` must be
712        // opened with credentials (a non-None principal). The credentialed
713        // constructors pass the principal through finish_open; the plain
714        // open/open_encrypted paths pass None and are rejected here. A brand-
715        // new database (`existing = false`) never has require_auth set yet
716        // (create_with_credentials sets it in the catalog before construction
717        // AND passes the principal), so the check only gates the reopen path.
718        if existing && cat.require_auth && principal.is_none() {
719            return Err(MongrelError::AuthRequired);
720        }
721
722        Ok(Self {
723            root,
724            catalog: RwLock::new(cat),
725            epoch,
726            snapshots,
727            page_cache,
728            decoded_cache,
729            commit_lock,
730            shared_wal,
731            next_txn_id: txn_ids,
732            tables: RwLock::new(tables),
733            kek,
734            ddl_lock: Mutex::new(()),
735            meta_dek,
736            conflicts: crate::txn::ConflictIndex::new(),
737            active_txns: crate::txn::ActiveTxns::new(),
738            poisoned,
739            group,
740            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
741            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
742            spill_hook: Mutex::new(None),
743            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
744            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
745            trigger_max_loop_iterations: AtomicU32::new(
746                TriggerConfig::default().max_loop_iterations,
747            ),
748            _lock: lock_file,
749            notify: {
750                let (tx, _rx) = tokio::sync::broadcast::channel(256);
751                tx
752            },
753            principal: RwLock::new(principal),
754        })
755    }
756
757    /// The current reader-visible epoch.
758    pub fn visible_epoch(&self) -> Epoch {
759        self.epoch.visible()
760    }
761
762    /// Clone the in-memory catalog (for diagnostics / tests).
763    pub fn catalog_snapshot(&self) -> Catalog {
764        self.catalog.read().clone()
765    }
766
767    /// The filesystem root this database was opened/created at.
768    pub fn root(&self) -> &Path {
769        &self.root
770    }
771
772    /// Resolve a table name → id (live tables only). pub(crate) so the
773    /// transaction layer can stage by name.
774    pub fn table_id(&self, name: &str) -> Result<u64> {
775        let cat = self.catalog.read();
776        cat.live(name)
777            .map(|e| e.table_id)
778            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
779    }
780
781    pub fn procedures(&self) -> Vec<StoredProcedure> {
782        self.catalog
783            .read()
784            .procedures
785            .iter()
786            .map(|p| p.procedure.clone())
787            .collect()
788    }
789
790    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
791        self.catalog
792            .read()
793            .procedures
794            .iter()
795            .find(|p| p.procedure.name == name)
796            .map(|p| p.procedure.clone())
797    }
798
799    pub fn create_procedure(&self, mut procedure: StoredProcedure) -> Result<StoredProcedure> {
800        self.require(&crate::auth::Permission::Ddl)?;
801        let _g = self.ddl_lock.lock();
802        procedure.validate()?;
803        self.validate_procedure_references(&procedure)?;
804        {
805            let cat = self.catalog.read();
806            if cat
807                .procedures
808                .iter()
809                .any(|p| p.procedure.name == procedure.name)
810            {
811                return Err(MongrelError::InvalidArgument(format!(
812                    "procedure {:?} already exists",
813                    procedure.name
814                )));
815            }
816        }
817        let commit_lock = Arc::clone(&self.commit_lock);
818        let _c = commit_lock.lock();
819        let epoch = self.epoch.bump_assigned();
820        procedure.created_epoch = epoch.0;
821        procedure.updated_epoch = epoch.0;
822        {
823            let mut cat = self.catalog.write();
824            cat.procedures.push(ProcedureEntry::from(procedure.clone()));
825            cat.db_epoch = epoch.0;
826        }
827        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
828        self.advance_visible(epoch);
829        Ok(procedure)
830    }
831
832    pub fn create_or_replace_procedure(
833        &self,
834        procedure: StoredProcedure,
835    ) -> Result<StoredProcedure> {
836        let _g = self.ddl_lock.lock();
837        procedure.validate()?;
838        self.validate_procedure_references(&procedure)?;
839        let commit_lock = Arc::clone(&self.commit_lock);
840        let _c = commit_lock.lock();
841        let epoch = self.epoch.bump_assigned();
842        let replaced = {
843            let mut cat = self.catalog.write();
844            let next = match cat
845                .procedures
846                .iter()
847                .position(|p| p.procedure.name == procedure.name)
848            {
849                Some(idx) => {
850                    let next = cat.procedures[idx]
851                        .procedure
852                        .replaced(procedure.clone(), epoch.0)?;
853                    cat.procedures[idx] = ProcedureEntry::from(next.clone());
854                    next
855                }
856                None => {
857                    let mut next = procedure;
858                    next.created_epoch = epoch.0;
859                    next.updated_epoch = epoch.0;
860                    cat.procedures.push(ProcedureEntry::from(next.clone()));
861                    next
862                }
863            };
864            cat.db_epoch = epoch.0;
865            next
866        };
867        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
868        self.advance_visible(epoch);
869        Ok(replaced)
870    }
871
872    pub fn drop_procedure(&self, name: &str) -> Result<()> {
873        self.require(&crate::auth::Permission::Ddl)?;
874        let _g = self.ddl_lock.lock();
875        let commit_lock = Arc::clone(&self.commit_lock);
876        let _c = commit_lock.lock();
877        let epoch = self.epoch.bump_assigned();
878        {
879            let mut cat = self.catalog.write();
880            let before = cat.procedures.len();
881            cat.procedures.retain(|p| p.procedure.name != name);
882            if cat.procedures.len() == before {
883                return Err(MongrelError::NotFound(format!(
884                    "procedure {name:?} not found"
885                )));
886            }
887            cat.db_epoch = epoch.0;
888        }
889        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
890        self.advance_visible(epoch);
891        Ok(())
892    }
893
894    // ── User / role / credentials management ─────────────────────────────
895
896    /// List all catalog users (password hashes included — callers should not
897    /// serialize them externally).
898    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
899        self.catalog.read().users.clone()
900    }
901
902    /// List all catalog roles.
903    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
904        self.catalog.read().roles.clone()
905    }
906
907    /// Create a new user with an Argon2id-hashed password.
908    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
909        self.require(&crate::auth::Permission::Admin)?;
910        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
911        let epoch = self.epoch.bump_assigned();
912        let id = {
913            let mut cat = self.catalog.write();
914            if cat.users.iter().any(|u| u.username == username) {
915                return Err(MongrelError::InvalidArgument(format!(
916                    "user {username:?} already exists"
917                )));
918            }
919            cat.next_user_id += 1;
920            let id = cat.next_user_id;
921            let entry = crate::auth::UserEntry {
922                id,
923                username: username.into(),
924                password_hash: hash,
925                roles: Vec::new(),
926                is_admin: false,
927                created_epoch: epoch.0,
928            };
929            cat.users.push(entry.clone());
930            cat.db_epoch = epoch.0;
931            entry
932        };
933        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
934        self.advance_visible(epoch);
935        Ok(id)
936    }
937
938    /// Drop a user by username.
939    pub fn drop_user(&self, username: &str) -> Result<()> {
940        self.require(&crate::auth::Permission::Admin)?;
941        let epoch = self.epoch.bump_assigned();
942        {
943            let mut cat = self.catalog.write();
944            let before = cat.users.len();
945            cat.users.retain(|u| u.username != username);
946            if cat.users.len() == before {
947                return Err(MongrelError::NotFound(format!(
948                    "user {username:?} not found"
949                )));
950            }
951            cat.db_epoch = epoch.0;
952        }
953        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
954        self.advance_visible(epoch);
955        Ok(())
956    }
957
958    /// Change a user's password.
959    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
960        self.require(&crate::auth::Permission::Admin)?;
961        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
962        let epoch = self.epoch.bump_assigned();
963        {
964            let mut cat = self.catalog.write();
965            let user = cat
966                .users
967                .iter_mut()
968                .find(|u| u.username == username)
969                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
970            user.password_hash = hash;
971            cat.db_epoch = epoch.0;
972        }
973        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
974        self.advance_visible(epoch);
975        Ok(())
976    }
977
978    /// Verify credentials. Returns `Some(entry)` on success, `None` on
979    /// mismatch, `Err` on engine error.
980    pub fn verify_user(
981        &self,
982        username: &str,
983        password: &str,
984    ) -> Result<Option<crate::auth::UserEntry>> {
985        let cat = self.catalog.read();
986        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
987            return Ok(None);
988        };
989        if user.password_hash.is_empty() {
990            return Ok(None);
991        }
992        let ok = crate::auth::verify_password(password, &user.password_hash)
993            .map_err(MongrelError::Other)?;
994        if ok {
995            Ok(Some(user.clone()))
996        } else {
997            Ok(None)
998        }
999    }
1000
1001    /// Grant admin privileges to a user (bypasses all permission checks).
1002    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
1003        self.require(&crate::auth::Permission::Admin)?;
1004        let epoch = self.epoch.bump_assigned();
1005        {
1006            let mut cat = self.catalog.write();
1007            let user = cat
1008                .users
1009                .iter_mut()
1010                .find(|u| u.username == username)
1011                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
1012            user.is_admin = is_admin;
1013            cat.db_epoch = epoch.0;
1014        }
1015        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1016        self.advance_visible(epoch);
1017        Ok(())
1018    }
1019
1020    /// Create a new role.
1021    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
1022        self.require(&crate::auth::Permission::Admin)?;
1023        let epoch = self.epoch.bump_assigned();
1024        let entry = {
1025            let mut cat = self.catalog.write();
1026            if cat.roles.iter().any(|r| r.name == name) {
1027                return Err(MongrelError::InvalidArgument(format!(
1028                    "role {name:?} already exists"
1029                )));
1030            }
1031            let entry = crate::auth::RoleEntry {
1032                name: name.into(),
1033                permissions: Vec::new(),
1034                created_epoch: epoch.0,
1035            };
1036            cat.roles.push(entry.clone());
1037            cat.db_epoch = epoch.0;
1038            entry
1039        };
1040        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1041        self.advance_visible(epoch);
1042        Ok(entry)
1043    }
1044
1045    /// Drop a role by name.
1046    pub fn drop_role(&self, name: &str) -> Result<()> {
1047        self.require(&crate::auth::Permission::Admin)?;
1048        let epoch = self.epoch.bump_assigned();
1049        {
1050            let mut cat = self.catalog.write();
1051            let before = cat.roles.len();
1052            cat.roles.retain(|r| r.name != name);
1053            if cat.roles.len() == before {
1054                return Err(MongrelError::NotFound(format!("role {name:?} not found")));
1055            }
1056            // Remove the role from all users.
1057            for user in &mut cat.users {
1058                user.roles.retain(|r| r != name);
1059            }
1060            cat.db_epoch = epoch.0;
1061        }
1062        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1063        self.advance_visible(epoch);
1064        Ok(())
1065    }
1066
1067    /// Grant a role to a user.
1068    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
1069        self.require(&crate::auth::Permission::Admin)?;
1070        let epoch = self.epoch.bump_assigned();
1071        {
1072            let mut cat = self.catalog.write();
1073            if !cat.roles.iter().any(|r| r.name == role_name) {
1074                return Err(MongrelError::NotFound(format!(
1075                    "role {role_name:?} not found"
1076                )));
1077            }
1078            let user = cat
1079                .users
1080                .iter_mut()
1081                .find(|u| u.username == username)
1082                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
1083            if !user.roles.contains(&role_name.to_string()) {
1084                user.roles.push(role_name.into());
1085            }
1086            cat.db_epoch = epoch.0;
1087        }
1088        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1089        self.advance_visible(epoch);
1090        Ok(())
1091    }
1092
1093    /// Revoke a role from a user.
1094    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
1095        self.require(&crate::auth::Permission::Admin)?;
1096        let epoch = self.epoch.bump_assigned();
1097        {
1098            let mut cat = self.catalog.write();
1099            let user = cat
1100                .users
1101                .iter_mut()
1102                .find(|u| u.username == username)
1103                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
1104            user.roles.retain(|r| r != role_name);
1105            cat.db_epoch = epoch.0;
1106        }
1107        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1108        self.advance_visible(epoch);
1109        Ok(())
1110    }
1111
1112    /// Grant a permission to a role.
1113    pub fn grant_permission(
1114        &self,
1115        role_name: &str,
1116        permission: crate::auth::Permission,
1117    ) -> Result<()> {
1118        self.require(&crate::auth::Permission::Admin)?;
1119        let epoch = self.epoch.bump_assigned();
1120        {
1121            let mut cat = self.catalog.write();
1122            let role = cat
1123                .roles
1124                .iter_mut()
1125                .find(|r| r.name == role_name)
1126                .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
1127            if !role.permissions.contains(&permission) {
1128                role.permissions.push(permission);
1129            }
1130            cat.db_epoch = epoch.0;
1131        }
1132        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1133        self.advance_visible(epoch);
1134        Ok(())
1135    }
1136
1137    /// Revoke a permission from a role.
1138    pub fn revoke_permission(
1139        &self,
1140        role_name: &str,
1141        permission: crate::auth::Permission,
1142    ) -> Result<()> {
1143        self.require(&crate::auth::Permission::Admin)?;
1144        let epoch = self.epoch.bump_assigned();
1145        {
1146            let mut cat = self.catalog.write();
1147            let role = cat
1148                .roles
1149                .iter_mut()
1150                .find(|r| r.name == role_name)
1151                .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
1152            role.permissions.retain(|p| p != &permission);
1153            cat.db_epoch = epoch.0;
1154        }
1155        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1156        self.advance_visible(epoch);
1157        Ok(())
1158    }
1159
1160    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
1161    /// permissions from their roles. Returns `None` if the user doesn't exist.
1162    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
1163        let cat = self.catalog.read();
1164        Self::resolve_principal_from_catalog(&cat, username)
1165    }
1166
1167    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
1168    /// without needing a constructed `Database`. Used by the credentialed open
1169    /// path (which must verify credentials before the `Database` exists) and
1170    /// by [`resolve_principal`](Self::resolve_principal).
1171    fn resolve_principal_from_catalog(
1172        cat: &Catalog,
1173        username: &str,
1174    ) -> Option<crate::auth::Principal> {
1175        let user = cat.users.iter().find(|u| u.username == username)?;
1176        let mut permissions = Vec::new();
1177        for role_name in &user.roles {
1178            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
1179                permissions.extend(role.permissions.iter().cloned());
1180            }
1181        }
1182        Some(crate::auth::Principal {
1183            username: user.username.clone(),
1184            is_admin: user.is_admin,
1185            roles: user.roles.clone(),
1186            permissions,
1187        })
1188    }
1189
1190    /// Check whether a user has a specific permission (via their roles).
1191    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
1192        match self.resolve_principal(username) {
1193            Some(p) => p.has_permission(permission),
1194            None => false,
1195        }
1196    }
1197
1198    /// Returns `true` if this database's catalog has `require_auth = true`.
1199    /// When true, every operation consults the cached [`Principal`] via
1200    /// [`require`](Self::require).
1201    pub fn require_auth_enabled(&self) -> bool {
1202        self.catalog.read().require_auth
1203    }
1204
1205    /// A snapshot of the cached principal for this handle, if any. `None` for
1206    /// databases opened without credentials (the default). Returns a clone
1207    /// because the principal lives behind an `RwLock`.
1208    pub fn principal(&self) -> Option<crate::auth::Principal> {
1209        self.principal.read().clone()
1210    }
1211
1212    /// Re-resolve the cached principal from the on-disk catalog. Long-lived
1213    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
1214    /// possibly made by a different handle to the same database — to pick up
1215    /// the new effective permissions without re-verifying the password.
1216    ///
1217    /// This reloads the catalog from disk first, so changes committed by other
1218    /// handles (or other processes) are visible. The username is taken from
1219    /// the existing cached principal; if the user has since been dropped,
1220    /// returns [`MongrelError::InvalidCredentials`].
1221    ///
1222    /// No-op (returns `Ok(())`) on a credentialless database, or on a
1223    /// credentialed database whose cached principal is `None`.
1224    pub fn refresh_principal(&self) -> Result<()> {
1225        let username = match self.principal.read().clone() {
1226            Some(p) => p.username,
1227            None => return Ok(()),
1228        };
1229        // Reload the catalog from disk so role/permission changes made by
1230        // other handles (or processes) are reflected. The in-memory catalog
1231        // is only updated by mutations on *this* handle.
1232        let cat = catalog::read(&self.root, self.meta_dek.as_ref())?
1233            .ok_or_else(|| MongrelError::NotFound("catalog vanished during refresh".into()))?;
1234        // Swap in the reloaded catalog so subsequent operations on this handle
1235        // also see the updated permissions/roles.
1236        *self.catalog.write() = cat.clone();
1237        match Self::resolve_principal_from_catalog(&cat, &username) {
1238            Some(p) => {
1239                *self.principal.write() = Some(p);
1240                Ok(())
1241            }
1242            None => Err(MongrelError::InvalidCredentials { username }),
1243        }
1244    }
1245
1246    /// Convert a credentialless database to a credentialed one: create the
1247    /// first admin user, set `require_auth = true`, and cache the admin
1248    /// principal on this handle so subsequent operations on the same handle
1249    /// continue to work. After this call, the database can only be reopened
1250    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
1251    ///
1252    /// Refuses if the database already has `require_auth = true`. This is
1253    /// the conversion path for existing databases; for fresh databases,
1254    /// `create_with_credentials` sets everything up atomically.
1255    ///
1256    /// See `docs/auth-enforcement-spec.md`.
1257    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
1258        let password_hash =
1259            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
1260        let epoch = self.epoch.bump_assigned();
1261        {
1262            let mut cat = self.catalog.write();
1263            if cat.require_auth {
1264                return Err(MongrelError::InvalidArgument(
1265                    "database already has require_auth enabled".into(),
1266                ));
1267            }
1268            // Reject a duplicate username so the bootstrap doesn't silently
1269            // shadow an existing user.
1270            if cat.users.iter().any(|u| u.username == admin_username) {
1271                return Err(MongrelError::InvalidArgument(format!(
1272                    "user {admin_username:?} already exists"
1273                )));
1274            }
1275            cat.next_user_id = cat.next_user_id.max(1);
1276            let id = cat.next_user_id;
1277            cat.next_user_id += 1;
1278            cat.users.push(crate::auth::UserEntry {
1279                id,
1280                username: admin_username.to_string(),
1281                password_hash,
1282                roles: Vec::new(),
1283                is_admin: true,
1284                created_epoch: epoch.0,
1285            });
1286            cat.require_auth = true;
1287            cat.db_epoch = epoch.0;
1288        }
1289        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1290        // Cache the admin principal on this handle.
1291        *self.principal.write() = Some(crate::auth::Principal {
1292            username: admin_username.to_string(),
1293            is_admin: true,
1294            roles: Vec::new(),
1295            permissions: Vec::new(),
1296        });
1297        Ok(())
1298    }
1299
1300    /// Enforcement check: if the catalog has `require_auth = true`, verify
1301    /// that the cached principal satisfies `perm`. Called by every
1302    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
1303    /// Table/Transaction/MongrelSession operations).
1304    ///
1305    /// On a credentialless database this is a no-op (`Ok(())`).
1306    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
1307        if !self.catalog.read().require_auth {
1308            return Ok(());
1309        }
1310        let guard = self.principal.read();
1311        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
1312        if p.has_permission(perm) {
1313            Ok(())
1314        } else {
1315            Err(MongrelError::PermissionDenied {
1316                required: perm.clone(),
1317                principal: p.username.clone(),
1318            })
1319        }
1320    }
1321
1322    pub fn triggers(&self) -> Vec<StoredTrigger> {
1323        self.catalog
1324            .read()
1325            .triggers
1326            .iter()
1327            .map(|t| t.trigger.clone())
1328            .collect()
1329    }
1330
1331    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
1332        self.catalog
1333            .read()
1334            .triggers
1335            .iter()
1336            .find(|t| t.trigger.name == name)
1337            .map(|t| t.trigger.clone())
1338    }
1339
1340    pub fn create_trigger(&self, mut trigger: StoredTrigger) -> Result<StoredTrigger> {
1341        self.require(&crate::auth::Permission::Ddl)?;
1342        let _g = self.ddl_lock.lock();
1343        trigger.validate()?;
1344        self.validate_trigger_references(&trigger)?;
1345        {
1346            let cat = self.catalog.read();
1347            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
1348                return Err(MongrelError::InvalidArgument(format!(
1349                    "trigger {:?} already exists",
1350                    trigger.name
1351                )));
1352            }
1353        }
1354        let commit_lock = Arc::clone(&self.commit_lock);
1355        let _c = commit_lock.lock();
1356        let epoch = self.epoch.bump_assigned();
1357        trigger.created_epoch = epoch.0;
1358        trigger.updated_epoch = epoch.0;
1359        {
1360            let mut cat = self.catalog.write();
1361            cat.triggers.push(TriggerEntry::from(trigger.clone()));
1362            cat.db_epoch = epoch.0;
1363        }
1364        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1365        self.advance_visible(epoch);
1366        Ok(trigger)
1367    }
1368
1369    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
1370        let _g = self.ddl_lock.lock();
1371        trigger.validate()?;
1372        self.validate_trigger_references(&trigger)?;
1373        let commit_lock = Arc::clone(&self.commit_lock);
1374        let _c = commit_lock.lock();
1375        let epoch = self.epoch.bump_assigned();
1376        let replaced = {
1377            let mut cat = self.catalog.write();
1378            let next = match cat
1379                .triggers
1380                .iter()
1381                .position(|t| t.trigger.name == trigger.name)
1382            {
1383                Some(idx) => {
1384                    let next = cat.triggers[idx]
1385                        .trigger
1386                        .replaced(trigger.clone(), epoch.0)?;
1387                    cat.triggers[idx] = TriggerEntry::from(next.clone());
1388                    next
1389                }
1390                None => {
1391                    let mut next = trigger;
1392                    next.created_epoch = epoch.0;
1393                    next.updated_epoch = epoch.0;
1394                    cat.triggers.push(TriggerEntry::from(next.clone()));
1395                    next
1396                }
1397            };
1398            cat.db_epoch = epoch.0;
1399            next
1400        };
1401        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1402        self.advance_visible(epoch);
1403        Ok(replaced)
1404    }
1405
1406    pub fn drop_trigger(&self, name: &str) -> Result<()> {
1407        self.require(&crate::auth::Permission::Ddl)?;
1408        let _g = self.ddl_lock.lock();
1409        let commit_lock = Arc::clone(&self.commit_lock);
1410        let _c = commit_lock.lock();
1411        let epoch = self.epoch.bump_assigned();
1412        {
1413            let mut cat = self.catalog.write();
1414            let before = cat.triggers.len();
1415            cat.triggers.retain(|t| t.trigger.name != name);
1416            if cat.triggers.len() == before {
1417                return Err(MongrelError::NotFound(format!(
1418                    "trigger {name:?} not found"
1419                )));
1420            }
1421            cat.db_epoch = epoch.0;
1422        }
1423        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1424        self.advance_visible(epoch);
1425        Ok(())
1426    }
1427
1428    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
1429        self.catalog.read().external_tables.clone()
1430    }
1431
1432    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
1433        self.catalog
1434            .read()
1435            .external_tables
1436            .iter()
1437            .find(|t| t.name == name)
1438            .cloned()
1439    }
1440
1441    pub fn create_external_table(
1442        &self,
1443        mut entry: ExternalTableEntry,
1444    ) -> Result<ExternalTableEntry> {
1445        self.require(&crate::auth::Permission::Ddl)?;
1446        let _g = self.ddl_lock.lock();
1447        entry.validate()?;
1448        {
1449            let cat = self.catalog.read();
1450            if cat.live(&entry.name).is_some()
1451                || cat.external_tables.iter().any(|t| t.name == entry.name)
1452            {
1453                return Err(MongrelError::InvalidArgument(format!(
1454                    "table {:?} already exists",
1455                    entry.name
1456                )));
1457            }
1458        }
1459        let commit_lock = Arc::clone(&self.commit_lock);
1460        let _c = commit_lock.lock();
1461        let epoch = self.epoch.bump_assigned();
1462        entry.created_epoch = epoch.0;
1463        {
1464            let mut cat = self.catalog.write();
1465            cat.external_tables.push(entry.clone());
1466            cat.db_epoch = epoch.0;
1467        }
1468        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1469        self.advance_visible(epoch);
1470        Ok(entry)
1471    }
1472
1473    pub fn drop_external_table(&self, name: &str) -> Result<()> {
1474        self.require(&crate::auth::Permission::Ddl)?;
1475        let _g = self.ddl_lock.lock();
1476        let commit_lock = Arc::clone(&self.commit_lock);
1477        let _c = commit_lock.lock();
1478        let epoch = self.epoch.bump_assigned();
1479        {
1480            let mut cat = self.catalog.write();
1481            let before = cat.external_tables.len();
1482            cat.external_tables.retain(|t| t.name != name);
1483            if cat.external_tables.len() == before {
1484                return Err(MongrelError::NotFound(format!(
1485                    "external table {name:?} not found"
1486                )));
1487            }
1488            cat.db_epoch = epoch.0;
1489        }
1490        let state_dir = self.root.join(VTAB_DIR).join(name);
1491        if state_dir.exists() {
1492            std::fs::remove_dir_all(state_dir)?;
1493        }
1494        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1495        self.advance_visible(epoch);
1496        Ok(())
1497    }
1498
1499    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
1500        let txn_id = self.alloc_txn_id();
1501        self.commit_transaction_with_external_states(
1502            txn_id,
1503            self.epoch.visible(),
1504            Vec::new(),
1505            vec![(name.to_string(), state.to_vec())],
1506            None,
1507        )
1508    }
1509
1510    pub fn trigger_config(&self) -> TriggerConfig {
1511        use std::sync::atomic::Ordering;
1512        TriggerConfig {
1513            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
1514            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
1515            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
1516        }
1517    }
1518
1519    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
1520        use std::sync::atomic::Ordering;
1521        if config.max_depth == 0 {
1522            return Err(MongrelError::InvalidArgument(
1523                "trigger max_depth must be greater than 0".into(),
1524            ));
1525        }
1526        self.trigger_recursive
1527            .store(config.recursive_triggers, Ordering::Relaxed);
1528        self.trigger_max_depth
1529            .store(config.max_depth, Ordering::Relaxed);
1530        self.trigger_max_loop_iterations
1531            .store(config.max_loop_iterations, Ordering::Relaxed);
1532        Ok(())
1533    }
1534
1535    pub fn set_recursive_triggers(&self, recursive: bool) {
1536        use std::sync::atomic::Ordering;
1537        self.trigger_recursive.store(recursive, Ordering::Relaxed);
1538    }
1539
1540    /// Subscribe to change-data-capture events. Returns a receiver that yields
1541    /// `ChangeEvent`s for every committed `Put`/`Delete`/`NOTIFY`.
1542    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
1543        self.notify.subscribe()
1544    }
1545
1546    /// Publish a notification message on a named channel. Reaches all active
1547    /// subscribers (daemon `/events`, application listeners).
1548    pub fn notify(&self, channel: &str, message: Option<String>) {
1549        let _ = self.notify.send(ChangeEvent {
1550            channel: channel.to_string(),
1551            table: String::new(),
1552            op: "notify".into(),
1553            epoch: self.epoch.visible().0,
1554            message,
1555        });
1556    }
1557
1558    pub fn call_procedure(
1559        &self,
1560        name: &str,
1561        args: HashMap<String, crate::Value>,
1562    ) -> Result<ProcedureCallResult> {
1563        // v1 requires ALL to call procedures on a require_auth database; a
1564        // finer SECURITY DEFINER-style marker is a future extension (spec §9
1565        // decision 1).
1566        self.require(&crate::auth::Permission::All)?;
1567        let procedure = self
1568            .procedure(name)
1569            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
1570        let args = bind_procedure_args(&procedure, args)?;
1571        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
1572        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
1573        if has_writes {
1574            let mut tx = self.begin();
1575            let run = (|| {
1576                for step in &procedure.body.steps {
1577                    let output =
1578                        self.execute_procedure_step(step, &args, &outputs, Some(&mut tx))?;
1579                    outputs.insert(step.id().to_string(), output);
1580                }
1581                eval_return_output(&procedure.body.return_value, &args, &outputs)
1582            })();
1583            match run {
1584                Ok(output) => {
1585                    let epoch = tx.commit()?.0;
1586                    Ok(ProcedureCallResult {
1587                        epoch: Some(epoch),
1588                        output,
1589                    })
1590                }
1591                Err(e) => {
1592                    tx.rollback();
1593                    Err(e)
1594                }
1595            }
1596        } else {
1597            for step in &procedure.body.steps {
1598                let output = self.execute_procedure_step(step, &args, &outputs, None)?;
1599                outputs.insert(step.id().to_string(), output);
1600            }
1601            Ok(ProcedureCallResult {
1602                epoch: None,
1603                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
1604            })
1605        }
1606    }
1607
1608    fn execute_procedure_step(
1609        &self,
1610        step: &ProcedureStep,
1611        args: &HashMap<String, crate::Value>,
1612        outputs: &HashMap<String, ProcedureCallOutput>,
1613        tx: Option<&mut crate::txn::Transaction<'_>>,
1614    ) -> Result<ProcedureCallOutput> {
1615        match step {
1616            ProcedureStep::NativeQuery {
1617                table,
1618                conditions,
1619                projection,
1620                limit,
1621                ..
1622            } => {
1623                let mut q = crate::Query::new();
1624                for condition in conditions {
1625                    q = q.and(eval_condition(condition, args, outputs)?);
1626                }
1627                let handle = self.table(table)?;
1628                let mut rows = handle.lock().query(&q)?;
1629                if let Some(limit) = limit {
1630                    rows.truncate(*limit);
1631                }
1632                let projection = projection.as_ref();
1633                Ok(ProcedureCallOutput::Rows(
1634                    rows.into_iter()
1635                        .map(|row| ProcedureCallRow {
1636                            row_id: Some(row.row_id),
1637                            columns: match projection {
1638                                Some(ids) => row
1639                                    .columns
1640                                    .into_iter()
1641                                    .filter(|(id, _)| ids.contains(id))
1642                                    .collect(),
1643                                None => row.columns,
1644                            },
1645                        })
1646                        .collect(),
1647                ))
1648            }
1649            ProcedureStep::Put {
1650                table,
1651                cells,
1652                returning,
1653                ..
1654            } => {
1655                let tx = tx.ok_or_else(|| {
1656                    MongrelError::InvalidArgument(
1657                        "write procedure step requires a transaction".into(),
1658                    )
1659                })?;
1660                let cells = eval_cells(cells, args, outputs)?;
1661                if *returning {
1662                    let out = tx.put_returning(table, cells)?;
1663                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
1664                        row_id: None,
1665                        columns: out.row.columns.into_iter().collect(),
1666                    }))
1667                } else {
1668                    tx.put(table, cells)?;
1669                    Ok(ProcedureCallOutput::Null)
1670                }
1671            }
1672            ProcedureStep::Upsert {
1673                table,
1674                cells,
1675                update_cells,
1676                returning,
1677                ..
1678            } => {
1679                let tx = tx.ok_or_else(|| {
1680                    MongrelError::InvalidArgument(
1681                        "write procedure step requires a transaction".into(),
1682                    )
1683                })?;
1684                let cells = eval_cells(cells, args, outputs)?;
1685                let action = match update_cells {
1686                    Some(update_cells) => {
1687                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
1688                    }
1689                    None => crate::UpsertAction::DoNothing,
1690                };
1691                let out = tx.upsert(table, cells, action)?;
1692                if *returning {
1693                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
1694                        row_id: None,
1695                        columns: out.row.columns.into_iter().collect(),
1696                    }))
1697                } else {
1698                    Ok(ProcedureCallOutput::Null)
1699                }
1700            }
1701            ProcedureStep::DeleteByPk { table, pk, .. } => {
1702                let tx = tx.ok_or_else(|| {
1703                    MongrelError::InvalidArgument(
1704                        "write procedure step requires a transaction".into(),
1705                    )
1706                })?;
1707                let pk = eval_value(pk, args, outputs)?;
1708                let handle = self.table(table)?;
1709                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
1710                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
1711                })?;
1712                tx.delete(table, row_id)?;
1713                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
1714            }
1715            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
1716                "DeleteRows procedure step is not supported by the core executor yet".into(),
1717            )),
1718            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
1719                "SqlQuery procedure step must be executed by mongreldb-query".into(),
1720            )),
1721        }
1722    }
1723
1724    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
1725        let cat = self.catalog.read();
1726        for step in &procedure.body.steps {
1727            let Some(table_name) = step.table() else {
1728                continue;
1729            };
1730            let schema = &cat
1731                .live(table_name)
1732                .ok_or_else(|| {
1733                    MongrelError::InvalidArgument(format!(
1734                        "procedure {:?} references unknown table {table_name:?}",
1735                        procedure.name
1736                    ))
1737                })?
1738                .schema;
1739            match step {
1740                ProcedureStep::NativeQuery {
1741                    conditions,
1742                    projection,
1743                    ..
1744                } => {
1745                    for condition in conditions {
1746                        validate_condition_columns(condition, schema)?;
1747                    }
1748                    if let Some(projection) = projection {
1749                        for id in projection {
1750                            validate_column_id(*id, schema)?;
1751                        }
1752                    }
1753                }
1754                ProcedureStep::Put { cells, .. } => {
1755                    for cell in cells {
1756                        validate_column_id(cell.column_id, schema)?;
1757                    }
1758                }
1759                ProcedureStep::Upsert {
1760                    cells,
1761                    update_cells,
1762                    ..
1763                } => {
1764                    for cell in cells {
1765                        validate_column_id(cell.column_id, schema)?;
1766                    }
1767                    if let Some(update_cells) = update_cells {
1768                        for cell in update_cells {
1769                            validate_column_id(cell.column_id, schema)?;
1770                        }
1771                    }
1772                }
1773                ProcedureStep::DeleteByPk { .. } => {
1774                    if schema.primary_key().is_none() {
1775                        return Err(MongrelError::InvalidArgument(format!(
1776                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
1777                            procedure.name
1778                        )));
1779                    }
1780                }
1781                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
1782            }
1783        }
1784        Ok(())
1785    }
1786
1787    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
1788        let cat = self.catalog.read();
1789        let target_schema = match &trigger.target {
1790            TriggerTarget::Table(target_name) => cat
1791                .live(target_name)
1792                .ok_or_else(|| {
1793                    MongrelError::InvalidArgument(format!(
1794                        "trigger {:?} references unknown target table {target_name:?}",
1795                        trigger.name
1796                    ))
1797                })?
1798                .schema
1799                .clone(),
1800            TriggerTarget::View(_) => Schema {
1801                columns: trigger.target_columns.clone(),
1802                ..Schema::default()
1803            },
1804        };
1805        for col in &trigger.update_of {
1806            if target_schema.column(col).is_none() {
1807                return Err(MongrelError::InvalidArgument(format!(
1808                    "trigger {:?} UPDATE OF references unknown column {col:?}",
1809                    trigger.name
1810                )));
1811            }
1812        }
1813        if let Some(expr) = &trigger.when {
1814            validate_trigger_expr(expr, &target_schema, trigger.event)?;
1815        }
1816        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
1817        for step in &trigger.program.steps {
1818            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
1819            {
1820                return Err(MongrelError::InvalidArgument(
1821                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
1822                ));
1823            }
1824            validate_trigger_step(
1825                step,
1826                &cat,
1827                &target_schema,
1828                trigger.event,
1829                &mut select_schemas,
1830            )?;
1831        }
1832        Ok(())
1833    }
1834
1835    /// Begin a new transaction reading at the current visible epoch.
1836    pub fn begin(&self) -> crate::txn::Transaction<'_> {
1837        self.begin_with_isolation(crate::txn::IsolationLevel::default())
1838    }
1839
1840    /// Begin a transaction with a specific isolation level.
1841    pub fn begin_with_isolation(
1842        &self,
1843        level: crate::txn::IsolationLevel,
1844    ) -> crate::txn::Transaction<'_> {
1845        let txn_id = self.alloc_txn_id();
1846        let epoch = match level {
1847            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
1848            _ => self.epoch.visible(),
1849        };
1850        let read = Snapshot::at(epoch);
1851        crate::txn::Transaction::new(self, txn_id, read)
1852    }
1853
1854    /// Begin a transaction whose trigger programs may route external-table DML
1855    /// through an application/query-layer module bridge.
1856    pub fn begin_with_external_trigger_bridge<'a>(
1857        &'a self,
1858        bridge: &'a dyn ExternalTriggerBridge,
1859    ) -> crate::txn::Transaction<'a> {
1860        let txn_id = self.alloc_txn_id();
1861        let read = Snapshot::at(self.epoch.visible());
1862        crate::txn::Transaction::new(self, txn_id, read).with_external_trigger_bridge(bridge)
1863    }
1864
1865    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
1866    pub fn transaction<T>(
1867        &self,
1868        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
1869    ) -> Result<T> {
1870        let mut tx = self.begin();
1871        match f(&mut tx) {
1872            Ok(out) => {
1873                tx.commit()?;
1874                Ok(out)
1875            }
1876            Err(e) => {
1877                tx.rollback();
1878                Err(e)
1879            }
1880        }
1881    }
1882
1883    /// Run `f` in a transaction with an external-trigger bridge; commit on
1884    /// `Ok`, rollback on `Err`.
1885    pub fn transaction_with_external_trigger_bridge<'a, T>(
1886        &'a self,
1887        bridge: &'a dyn ExternalTriggerBridge,
1888        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
1889    ) -> Result<T> {
1890        let mut tx = self.begin_with_external_trigger_bridge(bridge);
1891        match f(&mut tx) {
1892            Ok(out) => {
1893                tx.commit()?;
1894                Ok(out)
1895            }
1896            Err(e) => {
1897                tx.rollback();
1898                Err(e)
1899            }
1900        }
1901    }
1902
1903    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
1904    /// `Transaction::new` so registration happens **before** any read.
1905    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
1906        self.active_txns.register(epoch)
1907    }
1908
1909    fn fill_auto_increment_for_staging(
1910        &self,
1911        staging: &mut [(u64, crate::txn::Staged)],
1912    ) -> Result<()> {
1913        let tables = self.tables.read();
1914        for (table_id, staged) in staging {
1915            if let crate::txn::Staged::Put(cells) = staged {
1916                if let Some(handle) = tables.get(table_id) {
1917                    let mut t = handle.lock();
1918                    t.fill_auto_inc(cells)?;
1919                }
1920            }
1921        }
1922        Ok(())
1923    }
1924
1925    fn expand_table_triggers(
1926        &self,
1927        staging: &mut Vec<(u64, crate::txn::Staged)>,
1928        read_epoch: Epoch,
1929        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
1930        external_states: &mut Vec<(String, Vec<u8>)>,
1931    ) -> Result<()> {
1932        let mut external_writes = Vec::new();
1933        let config = self.trigger_config();
1934        if config.recursive_triggers {
1935            let chunk = std::mem::take(staging);
1936            let stacks = vec![Vec::new(); chunk.len()];
1937            *staging = self.expand_trigger_chunk(
1938                chunk,
1939                stacks,
1940                read_epoch,
1941                0,
1942                config.max_depth,
1943                &mut external_writes,
1944                &config,
1945            )?;
1946            self.apply_external_trigger_writes(
1947                external_writes,
1948                external_trigger_bridge,
1949                external_states,
1950                staging,
1951            )?;
1952            return Ok(());
1953        }
1954
1955        let mut expansion = self.expand_table_triggers_once(staging, read_epoch, None, &config)?;
1956        if !expansion.before.is_empty() {
1957            let mut final_staging = expansion.before;
1958            final_staging.extend(filter_ignored_staging(
1959                std::mem::take(staging),
1960                &expansion.ignored_indices,
1961            ));
1962            *staging = final_staging;
1963        } else if !expansion.ignored_indices.is_empty() {
1964            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
1965        }
1966        staging.append(&mut expansion.after);
1967        external_writes.append(&mut expansion.before_external);
1968        external_writes.append(&mut expansion.after_external);
1969        self.apply_external_trigger_writes(
1970            external_writes,
1971            external_trigger_bridge,
1972            external_states,
1973            staging,
1974        )?;
1975        Ok(())
1976    }
1977
1978    #[allow(clippy::too_many_arguments)]
1979    fn expand_trigger_chunk(
1980        &self,
1981        mut chunk: Vec<(u64, crate::txn::Staged)>,
1982        stacks: Vec<Vec<String>>,
1983        read_epoch: Epoch,
1984        depth: u32,
1985        max_depth: u32,
1986        external_writes: &mut Vec<ExternalTriggerWrite>,
1987        config: &TriggerConfig,
1988    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
1989        if chunk.is_empty() {
1990            return Ok(Vec::new());
1991        }
1992        self.fill_auto_increment_for_staging(&mut chunk)?;
1993        let expansion =
1994            self.expand_table_triggers_once(&mut chunk, read_epoch, Some(&stacks), config)?;
1995        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
1996            let stack = expansion
1997                .before_stacks
1998                .first()
1999                .or_else(|| expansion.after_stacks.first())
2000                .cloned()
2001                .unwrap_or_default();
2002            return Err(MongrelError::Conflict(format!(
2003                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
2004                Self::format_trigger_stack(&stack)
2005            )));
2006        }
2007
2008        let mut out = Vec::new();
2009        external_writes.extend(expansion.before_external);
2010        out.extend(self.expand_trigger_chunk(
2011            expansion.before,
2012            expansion.before_stacks,
2013            read_epoch,
2014            depth + 1,
2015            max_depth,
2016            external_writes,
2017            config,
2018        )?);
2019        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
2020        external_writes.extend(expansion.after_external);
2021        out.extend(self.expand_trigger_chunk(
2022            expansion.after,
2023            expansion.after_stacks,
2024            read_epoch,
2025            depth + 1,
2026            max_depth,
2027            external_writes,
2028            config,
2029        )?);
2030        Ok(out)
2031    }
2032
2033    fn apply_external_trigger_writes(
2034        &self,
2035        writes: Vec<ExternalTriggerWrite>,
2036        bridge: Option<&dyn ExternalTriggerBridge>,
2037        external_states: &mut Vec<(String, Vec<u8>)>,
2038        staging: &mut Vec<(u64, crate::txn::Staged)>,
2039    ) -> Result<()> {
2040        if writes.is_empty() {
2041            return Ok(());
2042        }
2043        let bridge = bridge.ok_or_else(|| {
2044            MongrelError::InvalidArgument(
2045                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
2046            )
2047        })?;
2048        for write in writes {
2049            let table = write.table().to_string();
2050            let entry = self.external_table(&table).ok_or_else(|| {
2051                MongrelError::NotFound(format!("external table {table:?} not found"))
2052            })?;
2053            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
2054            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
2055            external_states.push((table, result.state));
2056            for base_write in result.base_writes {
2057                match base_write {
2058                    ExternalTriggerBaseWrite::Put { table, cells } => {
2059                        let table_id = self.table_id(&table)?;
2060                        staging.push((table_id, crate::txn::Staged::Put(cells)));
2061                    }
2062                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
2063                        let table_id = self.table_id(&table)?;
2064                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
2065                    }
2066                }
2067            }
2068        }
2069        dedup_external_states_in_place(external_states);
2070        Ok(())
2071    }
2072
2073    fn expand_table_triggers_once(
2074        &self,
2075        staging: &mut Vec<(u64, crate::txn::Staged)>,
2076        read_epoch: Epoch,
2077        trigger_stacks: Option<&[Vec<String>]>,
2078        config: &TriggerConfig,
2079    ) -> Result<TriggerExpansion> {
2080        let triggers: Vec<StoredTrigger> = self
2081            .catalog
2082            .read()
2083            .triggers
2084            .iter()
2085            .filter(|entry| {
2086                entry.trigger.enabled
2087                    && matches!(
2088                        entry.trigger.timing,
2089                        TriggerTiming::Before | TriggerTiming::After
2090                    )
2091                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
2092            })
2093            .map(|entry| entry.trigger.clone())
2094            .collect();
2095        if triggers.is_empty() || staging.is_empty() {
2096            return Ok(TriggerExpansion::default());
2097        }
2098
2099        let before_triggers = triggers
2100            .iter()
2101            .filter(|trigger| trigger.timing == TriggerTiming::Before)
2102            .cloned()
2103            .collect::<Vec<_>>();
2104        let after_triggers = triggers
2105            .iter()
2106            .filter(|trigger| trigger.timing == TriggerTiming::After)
2107            .cloned()
2108            .collect::<Vec<_>>();
2109
2110        let mut before_added = Vec::new();
2111        let mut before_stacks = Vec::new();
2112        let mut before_external = Vec::new();
2113        let mut ignored_indices = std::collections::BTreeSet::new();
2114        if !before_triggers.is_empty() {
2115            let before_events =
2116                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?;
2117            let mut out = TriggerProgramOutput {
2118                added: &mut before_added,
2119                added_stacks: &mut before_stacks,
2120                added_external: &mut before_external,
2121                ignored_indices: &mut ignored_indices,
2122            };
2123            self.execute_triggers_for_events(
2124                &before_triggers,
2125                &before_events,
2126                Some(staging),
2127                &mut out,
2128                config,
2129                read_epoch,
2130            )?;
2131        }
2132
2133        let after_events = if after_triggers.is_empty() {
2134            Vec::new()
2135        } else {
2136            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?
2137                .into_iter()
2138                .filter(|event| {
2139                    !event
2140                        .op_indices
2141                        .iter()
2142                        .any(|idx| ignored_indices.contains(idx))
2143                })
2144                .collect()
2145        };
2146
2147        let mut after_added = Vec::new();
2148        let mut after_stacks = Vec::new();
2149        let mut after_external = Vec::new();
2150        let mut out = TriggerProgramOutput {
2151            added: &mut after_added,
2152            added_stacks: &mut after_stacks,
2153            added_external: &mut after_external,
2154            ignored_indices: &mut ignored_indices,
2155        };
2156        self.execute_triggers_for_events(
2157            &after_triggers,
2158            &after_events,
2159            None,
2160            &mut out,
2161            config,
2162            read_epoch,
2163        )?;
2164        Ok(TriggerExpansion {
2165            before: before_added,
2166            before_stacks,
2167            before_external,
2168            after: after_added,
2169            after_stacks,
2170            after_external,
2171            ignored_indices,
2172        })
2173    }
2174
2175    fn execute_triggers_for_events(
2176        &self,
2177        triggers: &[StoredTrigger],
2178        events: &[WriteEvent],
2179        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
2180        out: &mut TriggerProgramOutput<'_>,
2181        config: &TriggerConfig,
2182        read_epoch: Epoch,
2183    ) -> Result<()> {
2184        for event in events {
2185            for trigger in triggers {
2186                if event
2187                    .op_indices
2188                    .iter()
2189                    .any(|idx| out.ignored_indices.contains(idx))
2190                {
2191                    break;
2192                }
2193                let matches = {
2194                    let cat = self.catalog.read();
2195                    trigger_matches_event(trigger, event, &cat)?
2196                };
2197                if !matches {
2198                    continue;
2199                }
2200                if let Some(when) = &trigger.when {
2201                    if !eval_trigger_expr(when, event)? {
2202                        continue;
2203                    }
2204                }
2205                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
2206                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
2207                    return Err(MongrelError::Conflict(format!(
2208                        "trigger recursion cycle detected; trigger stack: {}",
2209                        Self::format_trigger_stack(&trigger_stack)
2210                    )));
2211                }
2212                let outcome = match staging.as_mut() {
2213                    Some(staging) => self.execute_trigger_program(
2214                        trigger,
2215                        event,
2216                        Some(&mut **staging),
2217                        out,
2218                        &trigger_stack,
2219                        config,
2220                        read_epoch,
2221                    )?,
2222                    None => self.execute_trigger_program(
2223                        trigger,
2224                        event,
2225                        None,
2226                        out,
2227                        &trigger_stack,
2228                        config,
2229                        read_epoch,
2230                    )?,
2231                };
2232                if outcome == TriggerProgramOutcome::Ignore {
2233                    out.ignored_indices.extend(event.op_indices.iter().copied());
2234                    break;
2235                }
2236            }
2237        }
2238        Ok(())
2239    }
2240
2241    fn trigger_events_for_staging(
2242        &self,
2243        staging: &[(u64, crate::txn::Staged)],
2244        read_epoch: Epoch,
2245        trigger_stacks: Option<&[Vec<String>]>,
2246    ) -> Result<Vec<WriteEvent>> {
2247        use crate::txn::Staged;
2248        use std::collections::{HashMap, VecDeque};
2249
2250        let snapshot = Snapshot::at(read_epoch);
2251        let cat = self.catalog.read();
2252        let mut table_names = HashMap::new();
2253        let mut table_schemas = HashMap::new();
2254        for entry in cat
2255            .tables
2256            .iter()
2257            .filter(|entry| matches!(entry.state, TableState::Live))
2258        {
2259            table_names.insert(entry.table_id, entry.name.clone());
2260            table_schemas.insert(entry.table_id, entry.schema.clone());
2261        }
2262        drop(cat);
2263
2264        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
2265        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
2266        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
2267
2268        for (idx, (table_id, staged)) in staging.iter().enumerate() {
2269            let Some(schema) = table_schemas.get(table_id) else {
2270                continue;
2271            };
2272            let Some(pk) = schema.primary_key() else {
2273                continue;
2274            };
2275            match staged {
2276                Staged::Delete(row_id) => {
2277                    let handle = self.table_by_id(*table_id)?;
2278                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
2279                        continue;
2280                    };
2281                    let Some(pk_value) = row.columns.get(&pk.id) else {
2282                        continue;
2283                    };
2284                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
2285                    delete_by_key
2286                        .entry((*table_id, pk_value.encode_key()))
2287                        .or_default()
2288                        .push_back(idx);
2289                }
2290                Staged::Put(cells) => {
2291                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
2292                        put_by_key
2293                            .entry((*table_id, value.encode_key()))
2294                            .or_default()
2295                            .push_back(idx);
2296                    }
2297                }
2298                Staged::Truncate => {}
2299            }
2300        }
2301
2302        let mut paired_delete = std::collections::HashSet::new();
2303        let mut paired_put = std::collections::HashSet::new();
2304        let mut events = Vec::new();
2305
2306        for (key, deletes) in delete_by_key.iter_mut() {
2307            let Some(puts) = put_by_key.get_mut(key) else {
2308                continue;
2309            };
2310            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
2311                paired_delete.insert(delete_idx);
2312                paired_put.insert(put_idx);
2313                let (table_id, _) = &staging[put_idx];
2314                let Some(table_name) = table_names.get(table_id).cloned() else {
2315                    continue;
2316                };
2317                let old = old_rows.get(&delete_idx).cloned();
2318                let new = match &staging[put_idx].1 {
2319                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
2320                    _ => None,
2321                };
2322                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
2323                events.push(WriteEvent {
2324                    table: table_name,
2325                    kind: TriggerEvent::Update,
2326                    old,
2327                    new,
2328                    changed_columns,
2329                    op_indices: vec![delete_idx, put_idx],
2330                    put_idx: Some(put_idx),
2331                    trigger_stack: Self::trigger_stack_for_indices(
2332                        trigger_stacks,
2333                        &[delete_idx, put_idx],
2334                    ),
2335                });
2336            }
2337        }
2338
2339        for (idx, (table_id, staged)) in staging.iter().enumerate() {
2340            let Some(table_name) = table_names.get(table_id).cloned() else {
2341                continue;
2342            };
2343            match staged {
2344                Staged::Put(cells) if !paired_put.contains(&idx) => {
2345                    let new = Some(TriggerRowImage::from_cells(cells));
2346                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
2347                    events.push(WriteEvent {
2348                        table: table_name,
2349                        kind: TriggerEvent::Insert,
2350                        old: None,
2351                        new,
2352                        changed_columns,
2353                        op_indices: vec![idx],
2354                        put_idx: Some(idx),
2355                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
2356                    });
2357                }
2358                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
2359                    let old = match old_rows.get(&idx).cloned() {
2360                        Some(old) => Some(old),
2361                        None => {
2362                            let handle = self.table_by_id(*table_id)?;
2363                            let row = handle.lock().get(*row_id, snapshot);
2364                            row.map(TriggerRowImage::from_row)
2365                        }
2366                    };
2367                    let Some(old) = old else {
2368                        continue;
2369                    };
2370                    let changed_columns = old.columns.keys().copied().collect();
2371                    events.push(WriteEvent {
2372                        table: table_name,
2373                        kind: TriggerEvent::Delete,
2374                        old: Some(old),
2375                        new: None,
2376                        changed_columns,
2377                        op_indices: vec![idx],
2378                        put_idx: None,
2379                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
2380                    });
2381                }
2382                Staged::Truncate => {}
2383                _ => {}
2384            }
2385        }
2386
2387        Ok(events)
2388    }
2389
2390    #[allow(clippy::too_many_arguments)]
2391    fn execute_trigger_program(
2392        &self,
2393        trigger: &StoredTrigger,
2394        event: &WriteEvent,
2395        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
2396        out: &mut TriggerProgramOutput<'_>,
2397        trigger_stack: &[String],
2398        config: &TriggerConfig,
2399        read_epoch: Epoch,
2400    ) -> Result<TriggerProgramOutcome> {
2401        let mut event = event.clone();
2402        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
2403        self.execute_trigger_steps(
2404            trigger,
2405            &trigger.program.steps,
2406            &mut event,
2407            staging,
2408            out,
2409            trigger_stack,
2410            config,
2411            &mut select_results,
2412            0,
2413            None,
2414            read_epoch,
2415        )
2416    }
2417
2418    #[allow(clippy::too_many_arguments)]
2419    fn execute_trigger_steps(
2420        &self,
2421        trigger: &StoredTrigger,
2422        steps: &[TriggerStep],
2423        event: &mut WriteEvent,
2424        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
2425        out: &mut TriggerProgramOutput<'_>,
2426        trigger_stack: &[String],
2427        config: &TriggerConfig,
2428        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
2429        depth: u32,
2430        selected: Option<&TriggerRowImage>,
2431        read_epoch: Epoch,
2432    ) -> Result<TriggerProgramOutcome> {
2433        let _ = depth;
2434        for step in steps {
2435            match step {
2436                TriggerStep::SetNew { cells } => {
2437                    if trigger.timing != TriggerTiming::Before {
2438                        return Err(MongrelError::InvalidArgument(
2439                            "SetNew trigger step is only valid in BEFORE triggers".into(),
2440                        ));
2441                    }
2442                    let put_idx = event.put_idx.ok_or_else(|| {
2443                        MongrelError::InvalidArgument(
2444                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
2445                        )
2446                    })?;
2447                    let staging = staging.as_deref_mut().ok_or_else(|| {
2448                        MongrelError::InvalidArgument(
2449                            "SetNew trigger step requires mutable trigger staging".into(),
2450                        )
2451                    })?;
2452                    let Some((_, crate::txn::Staged::Put(row_cells))) = staging.get_mut(put_idx)
2453                    else {
2454                        return Err(MongrelError::InvalidArgument(
2455                            "SetNew trigger step target row is not mutable".into(),
2456                        ));
2457                    };
2458                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
2459                        row_cells.retain(|(id, _)| *id != column_id);
2460                        row_cells.push((column_id, value.clone()));
2461                        if let Some(new) = &mut event.new {
2462                            new.columns.insert(column_id, value);
2463                        }
2464                    }
2465                    row_cells.sort_by_key(|(id, _)| *id);
2466                }
2467                TriggerStep::Insert { table, cells } => {
2468                    let cells = eval_trigger_cells(cells, event, selected)?;
2469                    if let Ok(table_id) = self.table_id(table) {
2470                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
2471                        out.added_stacks.push(trigger_stack.to_vec());
2472                    } else if self.external_table(table).is_some() {
2473                        out.added_external.push(ExternalTriggerWrite::Insert {
2474                            table: table.clone(),
2475                            cells,
2476                        });
2477                    } else {
2478                        return Err(MongrelError::NotFound(format!(
2479                            "trigger {:?} insert target {table:?} not found",
2480                            trigger.name
2481                        )));
2482                    }
2483                }
2484                TriggerStep::UpdateByPk { table, pk, cells } => {
2485                    let pk = eval_trigger_value(pk, event, selected)?;
2486                    let cells = eval_trigger_cells(cells, event, selected)?;
2487                    if self.external_table(table).is_some() {
2488                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
2489                            table: table.clone(),
2490                            pk,
2491                            cells,
2492                        });
2493                    } else {
2494                        let row_id = self
2495                            .table(table)?
2496                            .lock()
2497                            .lookup_pk(&pk.encode_key())
2498                            .ok_or_else(|| {
2499                                MongrelError::NotFound(format!(
2500                                    "trigger {:?} update target not found",
2501                                    trigger.name
2502                                ))
2503                            })?;
2504                        let handle = self.table(table)?;
2505                        let snapshot = Snapshot::at(self.epoch.visible());
2506                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
2507                            MongrelError::NotFound(format!(
2508                                "trigger {:?} update target not visible",
2509                                trigger.name
2510                            ))
2511                        })?;
2512                        let mut merged = old.columns;
2513                        for (column_id, value) in cells {
2514                            merged.insert(column_id, value);
2515                        }
2516                        out.added
2517                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
2518                        out.added_stacks.push(trigger_stack.to_vec());
2519                        out.added.push((
2520                            self.table_id(table)?,
2521                            crate::txn::Staged::Put(merged.into_iter().collect()),
2522                        ));
2523                        out.added_stacks.push(trigger_stack.to_vec());
2524                    }
2525                }
2526                TriggerStep::DeleteByPk { table, pk } => {
2527                    let pk = eval_trigger_value(pk, event, selected)?;
2528                    if self.external_table(table).is_some() {
2529                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
2530                            table: table.clone(),
2531                            pk,
2532                        });
2533                    } else {
2534                        let row_id = self
2535                            .table(table)?
2536                            .lock()
2537                            .lookup_pk(&pk.encode_key())
2538                            .ok_or_else(|| {
2539                                MongrelError::NotFound(format!(
2540                                    "trigger {:?} delete target not found",
2541                                    trigger.name
2542                                ))
2543                            })?;
2544                        out.added
2545                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
2546                        out.added_stacks.push(trigger_stack.to_vec());
2547                    }
2548                }
2549                TriggerStep::Select {
2550                    id,
2551                    table,
2552                    conditions,
2553                } => {
2554                    let schema = self.table(table)?.lock().schema().clone();
2555                    let snapshot = Snapshot::at(read_epoch);
2556                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
2557                    let mut matched = Vec::new();
2558                    for row in rows {
2559                        let image = TriggerRowImage::from_row(row);
2560                        let passes = conditions
2561                            .iter()
2562                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
2563                            .collect::<Result<Vec<_>>>()?
2564                            .into_iter()
2565                            .all(|b| b);
2566                        if passes {
2567                            matched.push(image);
2568                        }
2569                    }
2570                    if let Some(pk) = schema.primary_key() {
2571                        matched.sort_by(|a, b| {
2572                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
2573                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
2574                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
2575                        });
2576                    }
2577                    select_results.insert(id.clone(), matched);
2578                }
2579                TriggerStep::Foreach { id, steps } => {
2580                    let rows = select_results.get(id).ok_or_else(|| {
2581                        MongrelError::InvalidArgument(format!(
2582                            "trigger {:?} foreach references unknown select id {id:?}",
2583                            trigger.name
2584                        ))
2585                    })?;
2586                    if rows.len() > config.max_loop_iterations as usize {
2587                        return Err(MongrelError::InvalidArgument(format!(
2588                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
2589                            trigger.name, config.max_loop_iterations
2590                        )));
2591                    }
2592                    for row in rows.clone() {
2593                        let result = self.execute_trigger_steps(
2594                            trigger,
2595                            steps,
2596                            event,
2597                            staging.as_deref_mut(),
2598                            out,
2599                            trigger_stack,
2600                            config,
2601                            select_results,
2602                            depth + 1,
2603                            Some(&row),
2604                            read_epoch,
2605                        )?;
2606                        if result == TriggerProgramOutcome::Ignore {
2607                            return Ok(TriggerProgramOutcome::Ignore);
2608                        }
2609                    }
2610                }
2611                TriggerStep::DeleteWhere { table, conditions } => {
2612                    let schema = self.table(table)?.lock().schema().clone();
2613                    let snapshot = Snapshot::at(read_epoch);
2614                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
2615                    let table_id = self.table_id(table)?;
2616                    let mut to_delete = Vec::new();
2617                    for row in rows {
2618                        let image = TriggerRowImage::from_row(row.clone());
2619                        let passes = conditions
2620                            .iter()
2621                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
2622                            .collect::<Result<Vec<_>>>()?
2623                            .into_iter()
2624                            .all(|b| b);
2625                        if passes {
2626                            to_delete.push((table_id, row.row_id));
2627                        }
2628                    }
2629                    for (table_id, row_id) in to_delete {
2630                        out.added
2631                            .push((table_id, crate::txn::Staged::Delete(row_id)));
2632                        out.added_stacks.push(trigger_stack.to_vec());
2633                    }
2634                }
2635                TriggerStep::UpdateWhere {
2636                    table,
2637                    conditions,
2638                    cells,
2639                } => {
2640                    let schema = self.table(table)?.lock().schema().clone();
2641                    let snapshot = Snapshot::at(read_epoch);
2642                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
2643                    let table_id = self.table_id(table)?;
2644                    let mut to_update = Vec::new();
2645                    for row in rows {
2646                        let image = TriggerRowImage::from_row(row.clone());
2647                        let passes = conditions
2648                            .iter()
2649                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
2650                            .collect::<Result<Vec<_>>>()?
2651                            .into_iter()
2652                            .all(|b| b);
2653                        if passes {
2654                            let new_cells = cells
2655                                .iter()
2656                                .map(|cell| {
2657                                    Ok((
2658                                        cell.column_id,
2659                                        eval_trigger_value(&cell.value, event, Some(&image))?,
2660                                    ))
2661                                })
2662                                .collect::<Result<Vec<_>>>()?;
2663                            let mut merged = row.columns.clone();
2664                            for (column_id, value) in new_cells {
2665                                merged.insert(column_id, value);
2666                            }
2667                            to_update.push((table_id, row.row_id, merged));
2668                        }
2669                    }
2670                    for (table_id, row_id, merged) in to_update {
2671                        out.added
2672                            .push((table_id, crate::txn::Staged::Delete(row_id)));
2673                        out.added_stacks.push(trigger_stack.to_vec());
2674                        out.added.push((
2675                            table_id,
2676                            crate::txn::Staged::Put(merged.into_iter().collect()),
2677                        ));
2678                        out.added_stacks.push(trigger_stack.to_vec());
2679                    }
2680                }
2681                TriggerStep::Raise { action, message } => match action {
2682                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
2683                    TriggerRaiseAction::Abort
2684                    | TriggerRaiseAction::Fail
2685                    | TriggerRaiseAction::Rollback => {
2686                        let message = eval_trigger_value(message, event, selected)?;
2687                        return Err(MongrelError::Conflict(format!(
2688                            "trigger {:?} raised: {}; trigger stack: {}",
2689                            trigger.name,
2690                            trigger_message(message),
2691                            Self::format_trigger_stack(trigger_stack)
2692                        )));
2693                    }
2694                },
2695            }
2696        }
2697        Ok(TriggerProgramOutcome::Continue)
2698    }
2699
2700    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
2701        let Some(stacks) = stacks else {
2702            return Vec::new();
2703        };
2704        let mut out = Vec::new();
2705        for idx in indices {
2706            let Some(stack) = stacks.get(*idx) else {
2707                continue;
2708            };
2709            for name in stack {
2710                if !out.iter().any(|existing| existing == name) {
2711                    out.push(name.clone());
2712                }
2713            }
2714        }
2715        out
2716    }
2717
2718    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
2719        let mut out = stack.to_vec();
2720        out.push(trigger_name.to_string());
2721        out
2722    }
2723
2724    fn format_trigger_stack(stack: &[String]) -> String {
2725        if stack.is_empty() {
2726            "<root>".into()
2727        } else {
2728            stack.join(" -> ")
2729        }
2730    }
2731
2732    /// Authoritatively validate every declared constraint on the staged write
2733    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
2734    /// SET NULL actions into explicit child ops. Called from
2735    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
2736    /// violation as an `Err`, aborting the commit atomically. This is the
2737    /// server-side authority point: concurrent remote writers that each pass
2738    /// their own client-side checks still cannot both commit a violating batch.
2739    ///
2740    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
2741    /// intra-transaction dedup; concurrent-txn races are additionally caught by
2742    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
2743    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
2744    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
2745    /// RESTRICT-only (cascade-truncate is unsupported).
2746    fn validate_constraints(
2747        &self,
2748        staging: &mut Vec<(u64, crate::txn::Staged)>,
2749        read_epoch: Epoch,
2750    ) -> Result<()> {
2751        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
2752        use crate::memtable::Row;
2753        use crate::txn::Staged;
2754        use std::collections::HashSet;
2755
2756        let snapshot = Snapshot::at(read_epoch);
2757        let cat = self.catalog.read();
2758
2759        // Collect live (id, name, constraints-bearing?) for staged tables.
2760        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
2761            .tables
2762            .iter()
2763            .filter(|e| matches!(e.state, TableState::Live))
2764            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
2765            .collect();
2766
2767        // Fast path: bail if no live table declares any constraints at all.
2768        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
2769        if !any_constraints {
2770            return Ok(());
2771        }
2772
2773        // Lazily-loaded visible rows per table, shared across checks.
2774        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
2775        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
2776            if let Some(r) = rows_cache.get(&table_id) {
2777                return Ok(r.clone());
2778            }
2779            let handle = self.table_by_id(table_id)?;
2780            let rows = handle.lock().visible_rows(snapshot)?;
2781            rows_cache.insert(table_id, rows.clone());
2782            Ok(rows)
2783        };
2784
2785        // ── Phase A: expand ON DELETE CASCADE / SET NULL into explicit child
2786        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
2787        // enforced as a violation in Phase B. `cascaded` records every delete
2788        // we have already expanded so a self-referential CASCADE FK cannot loop.
2789        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
2790        loop {
2791            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
2792            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
2793                .iter()
2794                .filter_map(|(t, op)| match op {
2795                    Staged::Delete(rid) => Some((*t, *rid)),
2796                    _ => None,
2797                })
2798                .collect();
2799            for (table_id, rid) in deletes {
2800                if !cascaded.insert((table_id, rid.0)) {
2801                    continue;
2802                }
2803                let Some(tname) = live
2804                    .iter()
2805                    .find(|(t, _, _)| *t == table_id)
2806                    .map(|(_, n, _)| *n)
2807                else {
2808                    continue;
2809                };
2810                let parent_handle = self.table_by_id(table_id)?;
2811                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
2812                    continue;
2813                };
2814                for (child_id, _child_name, child_schema) in &live {
2815                    for fk in &child_schema.constraints.foreign_keys {
2816                        if fk.ref_table != tname {
2817                            continue;
2818                        }
2819                        let Some(parent_key) =
2820                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
2821                        else {
2822                            continue;
2823                        };
2824                        match fk.on_delete {
2825                            FkAction::Restrict => continue,
2826                            FkAction::Cascade => {
2827                                let child_rows = load_rows(*child_id)?;
2828                                for cr in &child_rows {
2829                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
2830                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
2831                                            == Some(parent_key.as_slice())
2832                                    {
2833                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
2834                                    }
2835                                }
2836                            }
2837                            FkAction::SetNull => {
2838                                let child_rows = load_rows(*child_id)?;
2839                                for cr in &child_rows {
2840                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
2841                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
2842                                            == Some(parent_key.as_slice())
2843                                    {
2844                                        // Re-emit the child row with the FK
2845                                        // columns set to NULL (delete + put).
2846                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
2847                                            .columns
2848                                            .iter()
2849                                            .map(|(k, v)| (*k, v.clone()))
2850                                            .collect();
2851                                        for cid in &fk.columns {
2852                                            cells.retain(|(k, _)| k != cid);
2853                                            cells.push((*cid, crate::memtable::Value::Null));
2854                                        }
2855                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
2856                                        new_ops.push((*child_id, Staged::Put(cells)));
2857                                    }
2858                                }
2859                            }
2860                        }
2861                    }
2862                }
2863            }
2864            if new_ops.is_empty() {
2865                break;
2866            }
2867            staging.extend(new_ops);
2868        }
2869
2870        // Rows staged for deletion in THIS transaction (now including cascaded
2871        // deletes). Used to exclude the old version of an updated row from
2872        // unique-existence scans.
2873        let staged_deletes: HashSet<(u64, u64)> = staging
2874            .iter()
2875            .filter_map(|(t, op)| match op {
2876                Staged::Delete(rid) => Some((*t, rid.0)),
2877                _ => None,
2878            })
2879            .collect();
2880
2881        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
2882        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
2883
2884        // ── Phase B: validate the fully-expanded staging set.
2885        for (table_id, op) in staging.iter() {
2886            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
2887            else {
2888                continue;
2889            };
2890            let cells_map: HashMap<u16, crate::memtable::Value>;
2891            match op {
2892                Staged::Put(cells) => {
2893                    cells_map = cells.iter().cloned().collect();
2894
2895                    // CHECK constraints.
2896                    if !schema.constraints.checks.is_empty() {
2897                        validate_checks(&schema.constraints.checks, &cells_map)?;
2898                    }
2899
2900                    // UNIQUE (non-PK) constraints.
2901                    for uc in &schema.constraints.uniques {
2902                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
2903                            continue; // NULL in a constrained column → skip (SQL).
2904                        };
2905                        let marker = (*table_id, uc.id, key.clone());
2906                        if !seen_unique.insert(marker) {
2907                            return Err(MongrelError::Conflict(format!(
2908                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
2909                                uc.name
2910                            )));
2911                        }
2912                        let rows = load_rows(*table_id)?;
2913                        for r in &rows {
2914                            // Skip rows this same transaction is deleting (the
2915                            // old version of an updated/cascade-deleted row).
2916                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
2917                                continue;
2918                            }
2919                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
2920                                if theirs == key {
2921                                    return Err(MongrelError::Conflict(format!(
2922                                        "UNIQUE constraint '{}' on table '{tname}' violated",
2923                                        uc.name
2924                                    )));
2925                                }
2926                            }
2927                        }
2928                    }
2929
2930                    // FK insert-side: parent must exist.
2931                    for fk in &schema.constraints.foreign_keys {
2932                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
2933                            continue; // NULL FK component → not checked (SQL).
2934                        };
2935                        let Some(parent_id) = cat
2936                            .tables
2937                            .iter()
2938                            .find(|t| t.name == fk.ref_table)
2939                            .map(|t| t.table_id)
2940                        else {
2941                            return Err(MongrelError::InvalidArgument(format!(
2942                                "FOREIGN KEY '{}' references unknown table '{}'",
2943                                fk.name, fk.ref_table
2944                            )));
2945                        };
2946                        let parent_rows = load_rows(parent_id)?;
2947                        let mut found = false;
2948                        for r in &parent_rows {
2949                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
2950                                continue;
2951                            }
2952                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
2953                                if pkey == child_key {
2954                                    found = true;
2955                                    break;
2956                                }
2957                            }
2958                        }
2959                        if !found {
2960                            return Err(MongrelError::Conflict(format!(
2961                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
2962                                fk.name, fk.ref_table
2963                            )));
2964                        }
2965                    }
2966                }
2967                Staged::Delete(rid) => {
2968                    // FK ON DELETE RESTRICT: a child row (whose FK action is
2969                    // RESTRICT) referencing this parent blocks the delete.
2970                    // CASCADE/SET NULL children were expanded in Phase A.
2971                    let parent_handle = self.table_by_id(*table_id)?;
2972                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
2973                        continue;
2974                    };
2975                    for (child_id, child_name, child_schema) in &live {
2976                        for fk in &child_schema.constraints.foreign_keys {
2977                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
2978                                continue;
2979                            }
2980                            let Some(parent_key) =
2981                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
2982                            else {
2983                                continue;
2984                            };
2985                            let child_rows = load_rows(*child_id)?;
2986                            for r in &child_rows {
2987                                // A child already being deleted by this txn
2988                                // (cascade/inline) is not a restrict violation.
2989                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
2990                                    continue;
2991                                }
2992                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
2993                                    if ck == parent_key {
2994                                        return Err(MongrelError::Conflict(format!(
2995                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
2996                                            fk.name
2997                                        )));
2998                                    }
2999                                }
3000                            }
3001                        }
3002                    }
3003                }
3004                Staged::Truncate => {
3005                    // Truncate is RESTRICT-only: reject if any child references
3006                    // this table (any FK action), since cascade-truncate is
3007                    // unsupported.
3008                    for (child_id, child_name, child_schema) in &live {
3009                        for fk in &child_schema.constraints.foreign_keys {
3010                            if fk.ref_table != tname {
3011                                continue;
3012                            }
3013                            let child_rows = load_rows(*child_id)?;
3014                            if child_rows
3015                                .iter()
3016                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
3017                            {
3018                                return Err(MongrelError::Conflict(format!(
3019                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
3020                                    fk.name
3021                                )));
3022                            }
3023                        }
3024                    }
3025                }
3026            }
3027        }
3028        Ok(())
3029    }
3030
3031    /// Seal a transaction (spec §9.3):
3032    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
3033    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
3034    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
3035    ///    group-sync, record conflict keys.
3036    /// 3. Publish — apply to tables, advance visible in-order.
3037    pub(crate) fn commit_transaction_with_external_states(
3038        &self,
3039        txn_id: u64,
3040        read_epoch: Epoch,
3041        mut staging: Vec<(u64, crate::txn::Staged)>,
3042        external_states: Vec<(String, Vec<u8>)>,
3043        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
3044    ) -> Result<Epoch> {
3045        use crate::memtable::Row;
3046        use crate::txn::{Staged, StagedOp, WriteKey};
3047        use crate::wal::Op;
3048        use std::collections::hash_map::DefaultHasher;
3049        use std::hash::{Hash, Hasher};
3050        use std::sync::atomic::Ordering;
3051
3052        if self.poisoned.load(Ordering::Relaxed) {
3053            return Err(MongrelError::Other(
3054                "database poisoned by fsync error".into(),
3055            ));
3056        }
3057        let mut external_states = dedup_external_states(external_states);
3058        if !external_states.is_empty() {
3059            let cat = self.catalog.read();
3060            for (name, _) in &external_states {
3061                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
3062                    return Err(MongrelError::NotFound(format!(
3063                        "external table {name:?} not found"
3064                    )));
3065                }
3066            }
3067        }
3068
3069        // ── 1. Prepare: fill generated values, expand triggers, validate, then
3070        // derive write keys from the final atomic write set.
3071        self.fill_auto_increment_for_staging(&mut staging)?;
3072        self.expand_table_triggers(
3073            &mut staging,
3074            read_epoch,
3075            external_trigger_bridge,
3076            &mut external_states,
3077        )?;
3078        self.fill_auto_increment_for_staging(&mut staging)?;
3079        external_states = dedup_external_states(external_states);
3080
3081        // Validate declarative constraints (unique / FK / check) under the read
3082        // snapshot, outside the WAL mutex. Trigger-produced writes are included
3083        // here, so the batch either satisfies every declared constraint or is
3084        // rejected atomically.
3085        self.validate_constraints(&mut staging, read_epoch)?;
3086
3087        let write_keys = {
3088            let cat = self.catalog.read();
3089            let mut keys: Vec<WriteKey> = Vec::new();
3090            for (table_id, staged) in &staging {
3091                match staged {
3092                    Staged::Put(cells) => {
3093                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
3094                            for col in &entry.schema.columns {
3095                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
3096                                    if let Some((_, val)) =
3097                                        cells.iter().find(|(id, _)| *id == col.id)
3098                                    {
3099                                        let mut h = DefaultHasher::new();
3100                                        val.encode_key().hash(&mut h);
3101                                        keys.push(WriteKey::Unique {
3102                                            table_id: *table_id,
3103                                            index_id: 0,
3104                                            key_hash: h.finish(),
3105                                        });
3106                                    }
3107                                }
3108                            }
3109                            // Declared non-PK unique constraints register a
3110                            // `WriteKey::Unique` (namespace-separated from the
3111                            // PK's index_id==0 by setting the high bit) so two
3112                            // concurrent transactions inserting the same key
3113                            // cannot both commit. Rows with any NULL constrained
3114                            // column are skipped (SQL semantics).
3115                            for uc in &entry.schema.constraints.uniques {
3116                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
3117                                    &uc.columns,
3118                                    &cells.iter().cloned().collect(),
3119                                ) {
3120                                    let mut h = DefaultHasher::new();
3121                                    key_bytes.hash(&mut h);
3122                                    keys.push(WriteKey::Unique {
3123                                        table_id: *table_id,
3124                                        index_id: uc.id | 0x8000,
3125                                        key_hash: h.finish(),
3126                                    });
3127                                }
3128                            }
3129                        }
3130                    }
3131                    Staged::Delete(rid) => keys.push(WriteKey::Row {
3132                        table_id: *table_id,
3133                        row_id: rid.0,
3134                    }),
3135                    Staged::Truncate => keys.push(WriteKey::Table {
3136                        table_id: *table_id,
3137                    }),
3138                }
3139            }
3140            for (name, _) in &external_states {
3141                let mut h = DefaultHasher::new();
3142                name.hash(&mut h);
3143                keys.push(WriteKey::Unique {
3144                    table_id: EXTERNAL_TABLE_ID,
3145                    index_id: 0,
3146                    key_hash: h.finish(),
3147                });
3148            }
3149            keys
3150        };
3151
3152        // Opportunistic pruning.
3153        let min_active = self.active_txns.min_read_epoch();
3154        if min_active < u64::MAX {
3155            self.conflicts.prune_below(Epoch(min_active));
3156        }
3157
3158        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
3159        // §8.5, review fix #17). Snapshot the conflict-index version so the
3160        // sequencer only re-checks if new commits arrived in the interim.
3161        if self.conflicts.conflicts(&write_keys, read_epoch) {
3162            return Err(MongrelError::Conflict(
3163                "write-write conflict (pre-validate, first-committer-wins)".into(),
3164            ));
3165        }
3166        let pre_validate_version = self.conflicts.version();
3167
3168        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
3169        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
3170        // streamed as Put records; they are linked at publish time.
3171        let mut spilled: Vec<SpilledRun> = Vec::new();
3172        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
3173        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
3174        // as the spill runs are live (registered on first spill, dropped at the
3175        // end of this function on commit/abort/error).
3176        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
3177        {
3178            let mut table_bytes: HashMap<u64, usize> = HashMap::new();
3179            for (table_id, staged) in &staging {
3180                if let Staged::Put(cells) = staged {
3181                    *table_bytes.entry(*table_id).or_default() += cells.len() * 16;
3182                }
3183            }
3184            let tables = self.tables.read();
3185            for (&table_id, &bytes) in &table_bytes {
3186                if bytes as u64
3187                    <= self
3188                        .spill_threshold
3189                        .load(std::sync::atomic::Ordering::Relaxed)
3190                {
3191                    continue;
3192                }
3193                let Some(handle) = tables.get(&table_id) else {
3194                    continue;
3195                };
3196                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
3197                let mut t = handle.lock();
3198                let tdir = t.table_dir().to_path_buf();
3199                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
3200                std::fs::create_dir_all(&txn_dir)?;
3201                let run_id = t.alloc_run_id() as u128;
3202                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
3203
3204                let mut rows: Vec<Row> = Vec::new();
3205                for (tid, staged) in &staging {
3206                    if *tid != table_id {
3207                        continue;
3208                    }
3209                    if let Staged::Put(cells) = staged {
3210                        t.validate_cells_not_null(cells)?;
3211                        let row_id = t.alloc_row_id();
3212                        let mut row = Row::new(row_id, Epoch(0));
3213                        for (c, v) in cells {
3214                            row.columns.insert(*c, v.clone());
3215                        }
3216                        rows.push(row);
3217                    }
3218                }
3219                let schema = t.schema_ref().clone();
3220                let kek = t.kek_ref().cloned();
3221                let specs = t.indexable_column_specs();
3222                drop(t);
3223
3224                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
3225                    .uniform_epoch(true);
3226                if let Some(ref kek) = kek {
3227                    writer = writer.with_encryption(kek.as_ref(), specs);
3228                }
3229                let header = writer.write(&pending_path, &rows)?;
3230                let row_count = header.row_count;
3231                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
3232                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
3233
3234                spilled.push(SpilledRun {
3235                    table_id,
3236                    run_id,
3237                    pending_path,
3238                    rows,
3239                    row_count,
3240                    min_rid,
3241                    max_rid,
3242                });
3243                spilled_tables.insert(table_id);
3244            }
3245        }
3246
3247        // Test seam: let a test race `gc()` against this in-flight spill.
3248        if spill_guard.is_some() {
3249            if let Some(hook) = self.spill_hook.lock().as_ref() {
3250                hook();
3251            }
3252        }
3253
3254        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
3255        // Allocating row ids + building the rows here (lock order: table handle →
3256        // nothing) means the sequencer never locks a table handle while holding
3257        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
3258        // the table handle THEN the shared WAL; if the sequencer did the reverse
3259        // (WAL then handle) the two paths would deadlock (review fix: B1).
3260        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
3261        // Row ids are allocated here, before the sequencer's delta conflict
3262        // re-check, so a losing txn leaks the ids it reserved — harmless, the
3263        // u64 row-id space is monotonic and gaps are expected (spills do the same).
3264        let mut prebuilt: Vec<Option<Row>> = Vec::with_capacity(staging.len());
3265        {
3266            let tables = self.tables.read();
3267            for (table_id, staged) in &staging {
3268                match staged {
3269                    Staged::Put(cells) if !spilled_tables.contains(table_id) => {
3270                        let handle = tables.get(table_id).ok_or_else(|| {
3271                            MongrelError::NotFound(format!("table {table_id} not mounted"))
3272                        })?;
3273                        let mut t = handle.lock();
3274                        t.validate_cells_not_null(cells)?;
3275                        let row_id = t.alloc_row_id();
3276                        drop(t);
3277                        let mut row = Row::new(row_id, Epoch(0));
3278                        for (c, v) in cells {
3279                            row.columns.insert(*c, v.clone());
3280                        }
3281                        prebuilt.push(Some(row));
3282                    }
3283                    Staged::Put(_) | Staged::Delete(_) | Staged::Truncate => prebuilt.push(None),
3284                }
3285            }
3286        }
3287
3288        let mut prepared_external = Vec::with_capacity(external_states.len());
3289        for (name, state) in &external_states {
3290            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
3291            prepared_external.push((name.clone(), state.clone(), pending));
3292        }
3293
3294        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
3295        let added_runs: Vec<crate::wal::AddedRun> = spilled
3296            .iter()
3297            .map(|s| crate::wal::AddedRun {
3298                table_id: s.table_id,
3299                run_id: s.run_id,
3300                row_count: s.row_count,
3301                level: 0,
3302                min_row_id: s.min_rid,
3303                max_row_id: s.max_rid,
3304                content_hash: [0u8; 32],
3305            })
3306            .collect();
3307        let (new_epoch, applies, commit_seq) = {
3308            let mut wal = self.shared_wal.lock();
3309
3310            // Re-check only if the conflict index advanced since pre-validation
3311            // (bounded delta — spec §8.5, review fix #17). If the version is
3312            // unchanged, the pre-check result is still valid and the sequencer
3313            // does O(1) work regardless of write-set size.
3314            if self.conflicts.version() != pre_validate_version
3315                && self.conflicts.conflicts(&write_keys, read_epoch)
3316            {
3317                // Abort: this txn assigned no epoch yet, so drop the quarantined
3318                // spill runs we wrote during prepare instead of leaking them in
3319                // `_txn/` until the next GC/reopen sweep.
3320                drop(wal);
3321                for s in &spilled {
3322                    if let Some(parent) = s.pending_path.parent() {
3323                        let _ = std::fs::remove_dir_all(parent);
3324                    }
3325                }
3326                for (_, _, pending) in &prepared_external {
3327                    let _ = std::fs::remove_file(pending);
3328                }
3329                return Err(MongrelError::Conflict(
3330                    "write-write conflict (sequencer delta re-check)".into(),
3331                ));
3332            }
3333
3334            let new_epoch = self.epoch.bump_assigned();
3335            let mut applies: Vec<(u64, Vec<StagedOp>)> = Vec::new();
3336
3337            for (idx, (table_id, staged)) in staging.iter().enumerate() {
3338                // Skip puts for tables that were spilled — their data is in a
3339                // pending run, not in streamed Put records.
3340                if spilled_tables.contains(table_id) && matches!(staged, Staged::Put(_)) {
3341                    continue;
3342                }
3343                let mut ops = Vec::new();
3344                match staged {
3345                    Staged::Put(_) => {
3346                        // Stamp the pre-built row at the real assigned epoch.
3347                        let mut row = prebuilt[idx].take().expect("prebuilt put row");
3348                        row.committed_epoch = new_epoch;
3349                        let payload = bincode::serialize(&vec![row.clone()])
3350                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
3351                        wal.append(
3352                            txn_id,
3353                            *table_id,
3354                            Op::Put {
3355                                table_id: *table_id,
3356                                rows: payload,
3357                            },
3358                        )?;
3359                        ops.push(StagedOp::Put(row));
3360                    }
3361                    Staged::Delete(rid) => {
3362                        wal.append(
3363                            txn_id,
3364                            *table_id,
3365                            Op::Delete {
3366                                table_id: *table_id,
3367                                row_ids: vec![*rid],
3368                            },
3369                        )?;
3370                        ops.push(StagedOp::Delete(*rid));
3371                    }
3372                    Staged::Truncate => {
3373                        wal.append(
3374                            txn_id,
3375                            *table_id,
3376                            Op::TruncateTable {
3377                                table_id: *table_id,
3378                            },
3379                        )?;
3380                        ops.push(StagedOp::Truncate);
3381                    }
3382                }
3383                applies.push((*table_id, ops));
3384            }
3385
3386            for (name, state, _) in &prepared_external {
3387                wal.append(
3388                    txn_id,
3389                    EXTERNAL_TABLE_ID,
3390                    Op::ExternalTableState {
3391                        name: name.clone(),
3392                        state: state.clone(),
3393                    },
3394                )?;
3395            }
3396
3397            let commit_seq = wal.append_commit(txn_id, new_epoch, &added_runs)?;
3398
3399            // Record the conflict + assign the epoch under the WAL lock so commit
3400            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
3401            // moves out of this critical section to the group-commit coordinator
3402            // so concurrent committers share a single leader fsync.
3403            self.conflicts.record(&write_keys, new_epoch);
3404            (new_epoch, applies, commit_seq)
3405        };
3406
3407        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
3408        self.group
3409            .await_durable(&self.shared_wal, commit_seq)
3410            .inspect_err(|_| {
3411                self.poisoned.store(true, Ordering::Relaxed);
3412            })?;
3413
3414        // ── 3. Publish: link spilled runs + apply non-spilled ops ──
3415        {
3416            let tables = self.tables.read();
3417            // Link spilled runs first.
3418            for s in &spilled {
3419                if let Some(handle) = tables.get(&s.table_id) {
3420                    let mut t = handle.lock();
3421                    let dest = t.run_path(s.run_id as u64);
3422                    std::fs::rename(&s.pending_path, &dest)?;
3423                    // Clean up the now-empty `_txn/<txn_id>/` dir.
3424                    if let Some(parent) = s.pending_path.parent() {
3425                        let _ = std::fs::remove_dir_all(parent);
3426                    }
3427                    t.link_run(crate::manifest::RunRef {
3428                        run_id: s.run_id,
3429                        level: 0,
3430                        epoch_created: new_epoch.0,
3431                        row_count: s.row_count,
3432                    });
3433                    // Update indexes + live_count from the spilled rows WITHOUT
3434                    // materializing them in the memtable (P3.4): they are served
3435                    // from the linked uniform-epoch run, read at `new_epoch` via
3436                    // the RunRef. This keeps peak memory bounded for large txns.
3437                    t.apply_run_metadata(&s.rows)?;
3438                    t.invalidate_pending_cache();
3439                    t.persist_manifest(new_epoch)?;
3440                }
3441            }
3442            // Apply non-spilled ops.
3443            for (table_id, ops) in applies {
3444                if let Some(handle) = tables.get(&table_id) {
3445                    let mut t = handle.lock();
3446                    for op in ops {
3447                        match op {
3448                            StagedOp::Put(row) => t.apply_put_rows(vec![row])?,
3449                            StagedOp::Delete(rid) => t.apply_delete(rid, new_epoch),
3450                            StagedOp::Truncate => t.apply_truncate(new_epoch)?,
3451                        }
3452                    }
3453                    t.invalidate_pending_cache();
3454                    t.persist_manifest(new_epoch)?;
3455                }
3456            }
3457        }
3458        for (name, _, pending) in &prepared_external {
3459            publish_external_state_file(&self.root, name, pending)?;
3460        }
3461
3462        self.advance_visible(new_epoch);
3463        Ok(new_epoch)
3464    }
3465
3466    /// Advance `visible` in-order: epoch E becomes visible only once E and all
3467    /// prior unpublished epochs have finished publishing (spec §9.3e). The
3468    /// in-order gate lives on the shared [`EpochAuthority`] so this path, the
3469    /// single-table `Table::commit` path, and DDL all share one watermark and
3470    /// can never publish out of assigned order under concurrency.
3471    fn advance_visible(&self, published: Epoch) {
3472        self.epoch.publish_in_order(published);
3473    }
3474
3475    /// Register a read snapshot at the current visible epoch and return it with
3476    /// a guard that retains it for GC until dropped.
3477    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
3478        let e = self.epoch.visible();
3479        let g = self.snapshots.register(e);
3480        (Snapshot::at(e), g)
3481    }
3482
3483    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
3484    /// retention.
3485    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
3486        let e = self.epoch.visible();
3487        let g = self.snapshots.register_owned(e);
3488        (Snapshot::at(e), g)
3489    }
3490
3491    /// Names of all live tables.
3492    pub fn table_names(&self) -> Vec<String> {
3493        self.catalog
3494            .read()
3495            .tables
3496            .iter()
3497            .filter(|t| matches!(t.state, TableState::Live))
3498            .map(|t| t.name.clone())
3499            .collect()
3500    }
3501
3502    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
3503    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
3504    /// reaped on the next open. Call this as the last action before a
3505    /// short-lived process (CLI, one-shot script) exits. The daemon does not
3506    /// need this — its background auto-compactor handles run management.
3507    pub fn close(&self) -> Result<()> {
3508        for name in self.table_names() {
3509            if let Ok(handle) = self.table(&name) {
3510                if let Err(e) = handle.lock().close() {
3511                    eprintln!("[close] flush failed for {name}: {e}");
3512                }
3513            }
3514        }
3515        Ok(())
3516    }
3517
3518    /// Compact every mounted table: merge all sorted runs into one clean run
3519    /// so query cost stays flat (single-run fast path) instead of growing
3520    /// with run count. Tables with < 2 runs are skipped (no-op). Each table
3521    /// is locked individually for its own compaction; snapshot retention is
3522    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
3523    pub fn compact(&self) -> Result<(usize, usize)> {
3524        self.require(&crate::auth::Permission::Ddl)?;
3525        let mut compacted = 0;
3526        let mut skipped = 0;
3527        for name in self.table_names() {
3528            let Ok(handle) = self.table(&name) else {
3529                continue;
3530            };
3531            {
3532                let mut t = handle.lock();
3533                let before = t.run_count();
3534                if before < 2 {
3535                    skipped += 1;
3536                    continue;
3537                }
3538                match t.compact() {
3539                    Ok(()) => {
3540                        let after = t.run_count();
3541                        compacted += 1;
3542                        eprintln!("[compact] {name}: {before} -> {after} runs");
3543                    }
3544                    Err(e) => {
3545                        eprintln!("[compact] {name}: compaction failed: {e}");
3546                        skipped += 1;
3547                    }
3548                }
3549            }
3550        }
3551        Ok((compacted, skipped))
3552    }
3553
3554    /// Compact a single table by name. Returns `Ok(true)` if it was
3555    /// compacted, `Ok(false)` if skipped (< 2 runs).
3556    pub fn compact_table(&self, name: &str) -> Result<bool> {
3557        self.require(&crate::auth::Permission::Ddl)?;
3558        let handle = self.table(name)?;
3559        let mut t = handle.lock();
3560        let before = t.run_count();
3561        if before < 2 {
3562            return Ok(false);
3563        }
3564        t.compact()?;
3565        Ok(t.run_count() < before)
3566    }
3567
3568    /// Look up a live table by name.
3569    pub fn table(&self, name: &str) -> Result<TableHandle> {
3570        let cat = self.catalog.read();
3571        let entry = cat
3572            .live(name)
3573            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
3574        let id = entry.table_id;
3575        drop(cat);
3576        self.tables
3577            .read()
3578            .get(&id)
3579            .cloned()
3580            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
3581    }
3582
3583    /// Resolve a live table id → mounted handle (used by the constraint
3584    /// validation pass and other id-qualified internal paths).
3585    fn table_by_id(&self, id: u64) -> Result<TableHandle> {
3586        self.tables
3587            .read()
3588            .get(&id)
3589            .cloned()
3590            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
3591    }
3592
3593    /// Create a new table. The DDL is first logged to the shared WAL
3594    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
3595    /// BEFORE the in-memory catalog and table map are mutated; the catalog
3596    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
3597    /// that sees a stale catalog still recovers the table by replaying the Ddl.
3598    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
3599        use crate::wal::DdlOp;
3600        use std::sync::atomic::Ordering;
3601
3602        self.require(&crate::auth::Permission::Ddl)?;
3603        if self.poisoned.load(Ordering::Relaxed) {
3604            return Err(MongrelError::Other(
3605                "database poisoned by fsync error".into(),
3606            ));
3607        }
3608
3609        let _g = self.ddl_lock.lock();
3610        {
3611            let cat = self.catalog.read();
3612            if cat.live(name).is_some() {
3613                return Err(MongrelError::InvalidArgument(format!(
3614                    "table {name:?} already exists"
3615                )));
3616            }
3617        }
3618
3619        // Allocate id + epoch + txn id under the commit lock so the DDL commit
3620        // is serialized with data commits (in-order publish).
3621        let commit_lock = Arc::clone(&self.commit_lock);
3622        let _c = commit_lock.lock();
3623        let table_id = {
3624            let mut cat = self.catalog.write();
3625            let id = cat.next_table_id;
3626            cat.next_table_id += 1;
3627            id
3628        };
3629        let epoch = self.epoch.bump_assigned();
3630        let txn_id = self.alloc_txn_id();
3631
3632        // Stamp the schema_id with the unique table_id so every table in the
3633        // database has a distinct schema_id (caller-provided values are
3634        // ignored to prevent collisions).
3635        let mut schema = schema;
3636        schema.schema_id = table_id;
3637        // Defense in depth: reject an invalid schema BEFORE any durable
3638        // side-effect. `Table::create_in` re-validates, but by then the DDL has
3639        // already been appended to the shared WAL; a failing create_in would
3640        // leave a dangling entry that `recover_ddl_from_wal` replays without
3641        // re-validating, corrupting the catalog on reopen. Validating here
3642        // keeps the WAL free of schemas that can never be opened.
3643        schema.validate_auto_increment()?;
3644
3645        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
3646        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
3647        let schema_json = DdlOp::encode_schema(&schema)?;
3648        let commit_seq = {
3649            let mut wal = self.shared_wal.lock();
3650            wal.append(
3651                txn_id,
3652                table_id,
3653                crate::wal::Op::Ddl(DdlOp::CreateTable {
3654                    table_id,
3655                    name: name.to_string(),
3656                    schema_json,
3657                }),
3658            )?;
3659            wal.append_commit(txn_id, epoch, &[])?
3660        };
3661        self.group
3662            .await_durable(&self.shared_wal, commit_seq)
3663            .inspect_err(|_| {
3664                self.poisoned.store(true, Ordering::Relaxed);
3665            })?;
3666
3667        // 2. Create the on-disk table dir + manifest.
3668        let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
3669        std::fs::create_dir_all(&tdir)?;
3670        let ctx = SharedCtx {
3671            epoch: Arc::clone(&self.epoch),
3672            page_cache: Arc::clone(&self.page_cache),
3673            decoded_cache: Arc::clone(&self.decoded_cache),
3674            snapshots: Arc::clone(&self.snapshots),
3675            kek: self.kek.clone(),
3676            commit_lock: Arc::clone(&self.commit_lock),
3677            shared: Some(crate::engine::SharedWalCtx {
3678                wal: Arc::clone(&self.shared_wal),
3679                group: Arc::clone(&self.group),
3680                poisoned: Arc::clone(&self.poisoned),
3681                txn_ids: Arc::clone(&self.next_txn_id),
3682            }),
3683        };
3684        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
3685
3686        // 3. Mutate the in-memory catalog + mount the table, then rewrite the
3687        //    catalog checkpoint (lazy: outside the WAL critical section).
3688        {
3689            let mut cat = self.catalog.write();
3690            cat.tables.push(CatalogEntry {
3691                table_id,
3692                name: name.to_string(),
3693                schema,
3694                state: TableState::Live,
3695                created_epoch: epoch.0,
3696            });
3697        }
3698        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3699        self.tables
3700            .write()
3701            .insert(table_id, Arc::new(Mutex::new(table)));
3702
3703        self.advance_visible(epoch);
3704        Ok(table_id)
3705    }
3706
3707    /// Logically drop a table, logging the DDL through the shared WAL first.
3708    pub fn drop_table(&self, name: &str) -> Result<()> {
3709        use crate::wal::DdlOp;
3710        use std::sync::atomic::Ordering;
3711
3712        self.require(&crate::auth::Permission::Ddl)?;
3713        if self.poisoned.load(Ordering::Relaxed) {
3714            return Err(MongrelError::Other(
3715                "database poisoned by fsync error".into(),
3716            ));
3717        }
3718
3719        let _g = self.ddl_lock.lock();
3720        let table_id = {
3721            let cat = self.catalog.read();
3722            cat.live(name)
3723                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
3724                .table_id
3725        };
3726
3727        let commit_lock = Arc::clone(&self.commit_lock);
3728        let _c = commit_lock.lock();
3729        let epoch = self.epoch.bump_assigned();
3730        let txn_id = self.alloc_txn_id();
3731        let commit_seq = {
3732            let mut wal = self.shared_wal.lock();
3733            wal.append(
3734                txn_id,
3735                table_id,
3736                crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
3737            )?;
3738            wal.append_commit(txn_id, epoch, &[])?
3739        };
3740        self.group
3741            .await_durable(&self.shared_wal, commit_seq)
3742            .inspect_err(|_| {
3743                self.poisoned.store(true, Ordering::Relaxed);
3744            })?;
3745
3746        {
3747            let mut cat = self.catalog.write();
3748            let entry = cat
3749                .tables
3750                .iter_mut()
3751                .find(|t| t.table_id == table_id)
3752                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
3753            entry.state = TableState::Dropped { at_epoch: epoch.0 };
3754            cat.triggers.retain(|trigger| {
3755                !matches!(
3756                    &trigger.trigger.target,
3757                    TriggerTarget::Table(target) if target == name
3758                )
3759            });
3760        }
3761        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3762        self.tables.write().remove(&table_id);
3763
3764        self.advance_visible(epoch);
3765        Ok(())
3766    }
3767
3768    /// Rename a live table. `name` must exist and `new_name` must not collide
3769    /// with any live table; both checks run under `ddl_lock` so they are atomic
3770    /// with the rename and with concurrent `create_table` existence checks (no
3771    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
3772    /// side-effects. The rename is logged to the shared WAL as
3773    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
3774    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
3775    /// the in-memory object does not move — only the catalog name changes).
3776    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
3777        use crate::wal::DdlOp;
3778        use std::sync::atomic::Ordering;
3779
3780        self.require(&crate::auth::Permission::Ddl)?;
3781        if self.poisoned.load(Ordering::Relaxed) {
3782            return Err(MongrelError::Other(
3783                "database poisoned by fsync error".into(),
3784            ));
3785        }
3786
3787        // A no-op rename short-circuits before any locking, so it can never
3788        // trip the "target already exists" check (the source *is* that name).
3789        if name == new_name {
3790            return Ok(());
3791        }
3792        if new_name.is_empty() {
3793            return Err(MongrelError::InvalidArgument(
3794                "rename_table: new name must not be empty".into(),
3795            ));
3796        }
3797
3798        let _g = self.ddl_lock.lock();
3799        let table_id = {
3800            let cat = self.catalog.read();
3801            let src = cat
3802                .live(name)
3803                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
3804            // Target must be free. Checked under ddl_lock, which every other
3805            // DDL (create/rename/drop) also holds, so a concurrent operation
3806            // cannot claim `new_name` between this check and the catalog write.
3807            if cat.live(new_name).is_some() {
3808                return Err(MongrelError::InvalidArgument(format!(
3809                    "rename_table: a table named {new_name:?} already exists"
3810                )));
3811            }
3812            src.table_id
3813        };
3814
3815        let commit_lock = Arc::clone(&self.commit_lock);
3816        let _c = commit_lock.lock();
3817        let epoch = self.epoch.bump_assigned();
3818        let txn_id = self.alloc_txn_id();
3819        let commit_seq = {
3820            let mut wal = self.shared_wal.lock();
3821            wal.append(
3822                txn_id,
3823                table_id,
3824                crate::wal::Op::Ddl(DdlOp::RenameTable {
3825                    table_id,
3826                    new_name: new_name.to_string(),
3827                }),
3828            )?;
3829            wal.append_commit(txn_id, epoch, &[])?
3830        };
3831        self.group
3832            .await_durable(&self.shared_wal, commit_seq)
3833            .inspect_err(|_| {
3834                self.poisoned.store(true, Ordering::Relaxed);
3835            })?;
3836
3837        {
3838            let mut cat = self.catalog.write();
3839            let entry = cat
3840                .tables
3841                .iter_mut()
3842                .find(|t| t.table_id == table_id)
3843                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
3844            entry.name = new_name.to_string();
3845            for trigger in &mut cat.triggers {
3846                if matches!(
3847                    &trigger.trigger.target,
3848                    TriggerTarget::Table(target) if target == name
3849                ) {
3850                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
3851                }
3852            }
3853        }
3854        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3855        // The in-memory table object is keyed by table_id, not name, so it does
3856        // not move and live TableHandles remain valid.
3857        self.advance_visible(epoch);
3858        Ok(())
3859    }
3860
3861    pub fn alter_column(
3862        &self,
3863        table_name: &str,
3864        column_name: &str,
3865        change: AlterColumn,
3866    ) -> Result<ColumnDef> {
3867        use crate::wal::DdlOp;
3868        use std::sync::atomic::Ordering;
3869
3870        self.require(&crate::auth::Permission::Ddl)?;
3871        if self.poisoned.load(Ordering::Relaxed) {
3872            return Err(MongrelError::Other(
3873                "database poisoned by fsync error".into(),
3874            ));
3875        }
3876
3877        let _g = self.ddl_lock.lock();
3878        let table_id = {
3879            let cat = self.catalog.read();
3880            cat.live(table_name)
3881                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
3882                .table_id
3883        };
3884        let handle =
3885            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
3886                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
3887            })?;
3888        let mut table = handle.lock();
3889        let column = table.prepare_alter_column(column_name, &change)?;
3890        let renamed_column = (column.name != column_name).then(|| column.name.clone());
3891        if table
3892            .schema()
3893            .columns
3894            .iter()
3895            .find(|c| c.id == column.id)
3896            .is_some_and(|c| c == &column)
3897        {
3898            return Ok(column);
3899        }
3900
3901        let commit_lock = Arc::clone(&self.commit_lock);
3902        let _c = commit_lock.lock();
3903        let epoch = self.epoch.bump_assigned();
3904        let txn_id = self.alloc_txn_id();
3905        let column_json = DdlOp::encode_column(&column)?;
3906        let commit_seq = {
3907            let mut wal = self.shared_wal.lock();
3908            wal.append(
3909                txn_id,
3910                table_id,
3911                crate::wal::Op::Ddl(DdlOp::AlterTable {
3912                    table_id,
3913                    column_json,
3914                }),
3915            )?;
3916            wal.append_commit(txn_id, epoch, &[])?
3917        };
3918        self.group
3919            .await_durable(&self.shared_wal, commit_seq)
3920            .inspect_err(|_| {
3921                self.poisoned.store(true, Ordering::Relaxed);
3922            })?;
3923
3924        table.apply_altered_column(column.clone())?;
3925        let schema = table.schema().clone();
3926        drop(table);
3927
3928        {
3929            let mut cat = self.catalog.write();
3930            let entry = cat
3931                .tables
3932                .iter_mut()
3933                .find(|t| t.table_id == table_id)
3934                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
3935            entry.schema = schema;
3936            if let Some(new_column_name) = renamed_column {
3937                for trigger in &mut cat.triggers {
3938                    if matches!(
3939                        &trigger.trigger.target,
3940                        TriggerTarget::Table(target) if target == table_name
3941                    ) {
3942                        trigger.trigger = trigger.trigger.renamed_update_column(
3943                            column_name,
3944                            new_column_name.clone(),
3945                            epoch.0,
3946                        )?;
3947                    }
3948                }
3949            }
3950        }
3951        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3952
3953        self.advance_visible(epoch);
3954        Ok(column)
3955    }
3956
3957    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
3958    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
3959    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
3960    ///
3961    /// Returns the number of items reclaimed.
3962    pub fn gc(&self) -> Result<usize> {
3963        self.require(&crate::auth::Permission::Ddl)?;
3964        let min_active = self.snapshots.min_active(self.epoch.visible()).0;
3965        let mut reclaimed = 0;
3966
3967        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
3968        let cat = self.catalog.read();
3969        for entry in &cat.tables {
3970            if let TableState::Dropped { at_epoch } = entry.state {
3971                if at_epoch <= min_active {
3972                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
3973                    if tdir.exists() {
3974                        std::fs::remove_dir_all(&tdir)?;
3975                        reclaimed += 1;
3976                    }
3977                }
3978            }
3979        }
3980        drop(cat);
3981
3982        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
3983        // in-flight spill's dir (deleting it would lose the pending run and fail
3984        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
3985        // skip any id still registered in `active_spills`.
3986        let cat = self.catalog.read();
3987        for entry in &cat.tables {
3988            if !matches!(entry.state, TableState::Live) {
3989                continue;
3990            }
3991            let txn_dir = self
3992                .root
3993                .join(TABLES_DIR)
3994                .join(entry.table_id.to_string())
3995                .join("_txn");
3996            if !txn_dir.exists() {
3997                continue;
3998            }
3999            for sub in std::fs::read_dir(&txn_dir)? {
4000                let sub = sub?;
4001                let name = sub.file_name();
4002                let Some(name) = name.to_str() else { continue };
4003                // A non-numeric entry can't belong to a live txn — sweep it.
4004                let is_active = name
4005                    .parse::<u64>()
4006                    .map(|id| self.active_spills.is_active(id))
4007                    .unwrap_or(false);
4008                if is_active {
4009                    continue;
4010                }
4011                std::fs::remove_dir_all(sub.path())?;
4012                reclaimed += 1;
4013            }
4014        }
4015        drop(cat);
4016
4017        let external_names = {
4018            let cat = self.catalog.read();
4019            cat.external_tables
4020                .iter()
4021                .map(|entry| entry.name.clone())
4022                .collect::<std::collections::HashSet<_>>()
4023        };
4024        let vtab_dir = self.root.join(VTAB_DIR);
4025        if vtab_dir.exists() {
4026            for entry in std::fs::read_dir(&vtab_dir)? {
4027                let entry = entry?;
4028                let name = entry.file_name();
4029                let Some(name) = name.to_str() else { continue };
4030                if external_names.contains(name) {
4031                    continue;
4032                }
4033                let path = entry.path();
4034                if path.is_dir() {
4035                    std::fs::remove_dir_all(path)?;
4036                } else {
4037                    std::fs::remove_file(path)?;
4038                }
4039                reclaimed += 1;
4040            }
4041        }
4042
4043        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
4044        // can still need (spec §6.4). Each table deletes its own retired files
4045        // gated on `min_active` and persists its manifest.
4046        let tables = self.tables.read();
4047        for handle in tables.values() {
4048            reclaimed += handle.lock().reap_retiring(Epoch(min_active))?;
4049        }
4050
4051        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
4052        // segment on every reopen without truncating the prior ones, so rotated
4053        // segments accumulate. Once every live table's committed data is durable
4054        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
4055        // (non-active) segments are redundant for recovery and safe to delete —
4056        // an in-flight txn only ever appends to the active segment, which is
4057        // never deleted.
4058        let all_durable = self.active_spills.is_idle()
4059            && tables.values().all(|h| {
4060                let g = h.lock();
4061                g.memtable_len() == 0 && g.mutable_run_len() == 0
4062            });
4063        drop(tables);
4064        if all_durable {
4065            reclaimed += self.shared_wal.lock().gc_segments(u64::MAX)?;
4066        }
4067
4068        Ok(reclaimed)
4069    }
4070    fn alloc_txn_id(&self) -> u64 {
4071        let mut g = self.next_txn_id.lock();
4072        let id = *g;
4073        *g = g.wrapping_add(1);
4074        id
4075    }
4076
4077    /// Set the per-table spill threshold (bytes). When a transaction's staged
4078    /// bytes for a single table exceed this, the rows are written as a
4079    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
4080    pub fn set_spill_threshold(&self, bytes: u64) {
4081        self.spill_threshold
4082            .store(bytes, std::sync::atomic::Ordering::Relaxed);
4083    }
4084
4085    /// Test-only: install a hook invoked after a transaction writes its spill
4086    /// runs but before the sequencer, so a test can race `gc()` against an
4087    /// in-flight spill. Not part of the stable API.
4088    #[doc(hidden)]
4089    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
4090        *self.spill_hook.lock() = Some(Box::new(f));
4091    }
4092
4093    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
4094    /// this stays well below the number of committed transactions when commits
4095    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
4096    #[doc(hidden)]
4097    pub fn __wal_group_sync_count(&self) -> u64 {
4098        self.shared_wal.lock().group_sync_count()
4099    }
4100
4101    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
4102    /// contract that an fsync error would trigger in production.
4103    #[doc(hidden)]
4104    pub fn __poison(&self) {
4105        self.poisoned
4106            .store(true, std::sync::atomic::Ordering::Relaxed);
4107    }
4108
4109    /// Verify multi-table integrity (spec §16). For every live table this:
4110    /// authenticates the manifest; opens each `RunRef`'s file through
4111    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
4112    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
4113    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
4114    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
4115    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
4116    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
4117    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
4118    ///
4119    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
4120    /// full body, so this is an integrity tool, not a hot path.
4121    pub fn check(&self) -> Vec<CheckIssue> {
4122        let mut issues = Vec::new();
4123        let cat = self.catalog.read();
4124        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
4125        for entry in &cat.tables {
4126            if !matches!(entry.state, TableState::Live) {
4127                continue;
4128            }
4129            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
4130            let mut err = |sev: &str, desc: String| {
4131                issues.push(CheckIssue {
4132                    table_id: entry.table_id,
4133                    table_name: entry.name.clone(),
4134                    severity: sev.into(),
4135                    description: desc,
4136                });
4137            };
4138            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
4139                Ok(m) => m,
4140                Err(e) => {
4141                    err("error", format!("manifest read failed: {e}"));
4142                    continue;
4143                }
4144            };
4145            if m.flushed_epoch > m.current_epoch {
4146                err(
4147                    "error",
4148                    format!(
4149                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
4150                        m.flushed_epoch, m.current_epoch
4151                    ),
4152                );
4153            }
4154
4155            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
4156            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
4157            for rr in &m.runs {
4158                referenced.insert(rr.run_id);
4159                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
4160                if !run_path.exists() {
4161                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
4162                    continue;
4163                }
4164                match crate::sorted_run::RunReader::open(
4165                    &run_path,
4166                    entry.schema.clone(),
4167                    self.kek.clone(),
4168                ) {
4169                    Ok(reader) => {
4170                        if reader.row_count() as u64 != rr.row_count {
4171                            err(
4172                                "error",
4173                                format!(
4174                                    "run r-{} row count mismatch: manifest {} vs run {}",
4175                                    rr.run_id,
4176                                    rr.row_count,
4177                                    reader.row_count()
4178                                ),
4179                            );
4180                        }
4181                    }
4182                    Err(e) => {
4183                        err(
4184                            "error",
4185                            format!("run r-{} integrity check failed: {e}", rr.run_id),
4186                        );
4187                    }
4188                }
4189            }
4190
4191            // Compaction-superseded runs awaiting retention-gated deletion are
4192            // tracked in `retiring`; their files are expected on disk, so they
4193            // are not orphans.
4194            for r in &m.retiring {
4195                referenced.insert(r.run_id);
4196            }
4197
4198            // Orphan `.sr` files present on disk but absent from the manifest.
4199            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
4200                for ent in rd.flatten() {
4201                    let p = ent.path();
4202                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
4203                        continue;
4204                    }
4205                    let run_id = p
4206                        .file_stem()
4207                        .and_then(|s| s.to_str())
4208                        .and_then(|s| s.strip_prefix("r-"))
4209                        .and_then(|s| s.parse::<u128>().ok());
4210                    if let Some(id) = run_id {
4211                        if !referenced.contains(&id) {
4212                            err(
4213                                "warning",
4214                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
4215                            );
4216                        }
4217                    }
4218                }
4219            }
4220        }
4221
4222        let external_names = cat
4223            .external_tables
4224            .iter()
4225            .map(|entry| entry.name.clone())
4226            .collect::<std::collections::HashSet<_>>();
4227        let vtab_dir = self.root.join(VTAB_DIR);
4228        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
4229            for entry in entries.flatten() {
4230                let name = entry.file_name();
4231                let Some(name) = name.to_str() else { continue };
4232                if !external_names.contains(name) {
4233                    issues.push(CheckIssue {
4234                        table_id: EXTERNAL_TABLE_ID,
4235                        table_name: name.to_string(),
4236                        severity: "warning".into(),
4237                        description: format!(
4238                            "orphan external table state entry {:?} not referenced by the catalog",
4239                            entry.path()
4240                        ),
4241                    });
4242                }
4243            }
4244        }
4245
4246        // WAL retention / integrity invariant (spec §16): every on-disk WAL
4247        // segment must open (header magic + version, and the frame cipher must
4248        // be derivable for an encrypted WAL). A segment that won't open is
4249        // corrupt or truncated and would break crash recovery. `table_id` is
4250        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
4251        // never confuses a WAL issue with a real table.
4252        for (seg, msg) in self.shared_wal.lock().verify_segments() {
4253            issues.push(CheckIssue {
4254                table_id: WAL_TABLE_ID,
4255                table_name: "<wal>".into(),
4256                severity: "error".into(),
4257                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
4258            });
4259        }
4260        issues
4261    }
4262
4263    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
4264    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
4265    /// unmounts them from the live table map so the DB still opens.
4266    pub fn doctor(&self) -> Result<Vec<u64>> {
4267        // Hold the DDL lock for the whole operation to prevent concurrent
4268        // create_table/drop_table from racing the catalog/dir mutation.
4269        let _ddl = self.ddl_lock.lock();
4270        let issues = self.check();
4271        // A corrupt WAL segment is reported as an error but is NOT a table
4272        // problem — quarantining an innocent table cannot fix it (and the first
4273        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
4274        // them disjoint). The admin must address WAL corruption manually.
4275        let bad_tables: std::collections::HashSet<u64> = issues
4276            .iter()
4277            .filter(|i| {
4278                i.severity == "error"
4279                    && i.table_id != WAL_TABLE_ID
4280                    && i.table_id != EXTERNAL_TABLE_ID
4281            })
4282            .map(|i| i.table_id)
4283            .collect();
4284        if bad_tables.is_empty() {
4285            return Ok(Vec::new());
4286        }
4287
4288        let qdir = self.root.join("_quarantine");
4289        std::fs::create_dir_all(&qdir)?;
4290        let mut quarantined = Vec::new();
4291        for &table_id in &bad_tables {
4292            let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
4293            if tdir.exists() {
4294                let dest = qdir.join(table_id.to_string());
4295                std::fs::rename(&tdir, &dest)?;
4296            }
4297            {
4298                let mut cat = self.catalog.write();
4299                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
4300                    entry.state = TableState::Dropped {
4301                        at_epoch: self.epoch.visible().0,
4302                    };
4303                }
4304            }
4305            // Unmount the live handle so no further access reaches the moved dir.
4306            self.tables.write().remove(&table_id);
4307            quarantined.push(table_id);
4308        }
4309        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
4310        Ok(quarantined)
4311    }
4312
4313    /// The DB-wide KEK (if encrypted).
4314    #[allow(dead_code)]
4315    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
4316        self.kek.as_ref()
4317    }
4318
4319    /// Shared epoch authority (used by the transaction layer in P2).
4320    #[allow(dead_code)]
4321    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
4322        &self.epoch
4323    }
4324
4325    /// Shared snapshot registry (used by GC in P3.6).
4326    #[allow(dead_code)]
4327    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
4328        &self.snapshots
4329    }
4330}
4331
4332fn external_state_dir(root: &Path, name: &str) -> PathBuf {
4333    root.join(VTAB_DIR).join(name)
4334}
4335
4336fn filter_ignored_staging(
4337    staging: Vec<(u64, crate::txn::Staged)>,
4338    ignored_indices: &std::collections::BTreeSet<usize>,
4339) -> Vec<(u64, crate::txn::Staged)> {
4340    if ignored_indices.is_empty() {
4341        return staging;
4342    }
4343    staging
4344        .into_iter()
4345        .enumerate()
4346        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
4347        .collect()
4348}
4349
4350fn external_state_file(root: &Path, name: &str) -> PathBuf {
4351    external_state_dir(root, name).join("state.json")
4352}
4353
4354fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
4355    let path = external_state_file(root, name);
4356    match std::fs::read(path) {
4357        Ok(bytes) => Ok(bytes),
4358        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
4359        Err(e) => Err(e.into()),
4360    }
4361}
4362
4363fn current_external_state_bytes(
4364    root: &Path,
4365    external_states: &[(String, Vec<u8>)],
4366    name: &str,
4367) -> Result<Vec<u8>> {
4368    for (table, state) in external_states.iter().rev() {
4369        if table == name {
4370            return Ok(state.clone());
4371        }
4372    }
4373    read_external_state_file(root, name)
4374}
4375
4376fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
4377    let mut out = external_states;
4378    dedup_external_states_in_place(&mut out);
4379    out
4380}
4381
4382fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
4383    let mut seen = std::collections::HashSet::new();
4384    let mut out = Vec::with_capacity(external_states.len());
4385    for (name, state) in std::mem::take(external_states).into_iter().rev() {
4386        if seen.insert(name.clone()) {
4387            out.push((name, state));
4388        }
4389    }
4390    out.reverse();
4391    *external_states = out;
4392}
4393
4394fn prepare_external_state_file(
4395    root: &Path,
4396    name: &str,
4397    state: &[u8],
4398    txn_id: u64,
4399) -> Result<PathBuf> {
4400    let dir = external_state_dir(root, name);
4401    std::fs::create_dir_all(&dir)?;
4402    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
4403    {
4404        let mut file = std::fs::File::create(&pending)?;
4405        file.write_all(state)?;
4406        file.sync_all()?;
4407    }
4408    Ok(pending)
4409}
4410
4411fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
4412    let path = external_state_file(root, name);
4413    std::fs::rename(pending, &path)?;
4414    if let Ok(dir) = std::fs::File::open(external_state_dir(root, name)) {
4415        let _ = dir.sync_all();
4416    }
4417    Ok(())
4418}
4419
4420fn write_external_state_file(root: &Path, name: &str, state: &[u8]) -> Result<()> {
4421    let pending = prepare_external_state_file(root, name, state, 0)?;
4422    publish_external_state_file(root, name, &pending)
4423}
4424
4425/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
4426///
4427/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
4428/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
4429/// 2 applies each committed data record (Put/Delete) to its table at the commit
4430/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
4431/// durable in a sorted run). Finally the shared epoch authority is raised to the
4432/// max committed epoch so the next commit continues monotonically.
4433fn recover_shared_wal(
4434    root: &Path,
4435    tables: &HashMap<u64, TableHandle>,
4436    epoch: &EpochAuthority,
4437    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
4438) -> Result<()> {
4439    use crate::memtable::Row;
4440    use crate::rowid::RowId;
4441    use crate::wal::{Op, SharedWal};
4442
4443    let records = SharedWal::replay_with_dek(root, wal_dek)?;
4444
4445    // Pass 1: committed-txn outcomes + collect spilled-run info.
4446    let mut committed: HashMap<u64, u64> = HashMap::new();
4447    let mut spilled_to_link: Vec<(
4448        u64, /*txn_id*/
4449        u64, /*epoch*/
4450        Vec<crate::wal::AddedRun>,
4451    )> = Vec::new();
4452    for r in &records {
4453        if let Op::TxnCommit {
4454            epoch: ce,
4455            ref added_runs,
4456        } = r.op
4457        {
4458            committed.insert(r.txn_id, ce);
4459            if !added_runs.is_empty() {
4460                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
4461            }
4462        }
4463    }
4464
4465    // Pass 2: stage data per table, gated by flushed_epoch.
4466    type TableStage = (Vec<Row>, Vec<(RowId, Epoch)>, Option<Epoch>, Epoch);
4467    let mut stage: HashMap<u64, TableStage> = HashMap::new();
4468    let mut max_epoch = epoch.visible().0;
4469    for r in records {
4470        let Some(&ce) = committed.get(&r.txn_id) else {
4471            continue; // aborted / in-flight — discard
4472        };
4473        let commit_epoch = Epoch(ce);
4474        max_epoch = max_epoch.max(ce);
4475        match r.op {
4476            Op::Put { table_id, rows } => {
4477                // Skip if this table already flushed past the commit epoch.
4478                let skip = tables
4479                    .get(&table_id)
4480                    .map(|h| h.lock().flushed_epoch() >= ce)
4481                    .unwrap_or(true);
4482                if skip {
4483                    continue;
4484                }
4485                let rows: Vec<Row> = match bincode::deserialize(&rows) {
4486                    Ok(v) => v,
4487                    Err(_) => continue,
4488                };
4489                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
4490                // at pending_epoch which equals the commit epoch, but be robust).
4491                let rows: Vec<Row> = rows
4492                    .into_iter()
4493                    .map(|mut row| {
4494                        row.committed_epoch = commit_epoch;
4495                        row
4496                    })
4497                    .collect();
4498                let entry = stage
4499                    .entry(table_id)
4500                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
4501                entry.0.extend(rows);
4502                entry.3 = commit_epoch;
4503            }
4504            Op::Delete { table_id, row_ids } => {
4505                let skip = tables
4506                    .get(&table_id)
4507                    .map(|h| h.lock().flushed_epoch() >= ce)
4508                    .unwrap_or(true);
4509                if skip {
4510                    continue;
4511                }
4512                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
4513                let entry = stage
4514                    .entry(table_id)
4515                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
4516                entry.1.extend(dels);
4517                entry.3 = commit_epoch;
4518            }
4519            Op::TruncateTable { table_id } => {
4520                let skip = tables
4521                    .get(&table_id)
4522                    .map(|h| h.lock().flushed_epoch() >= ce)
4523                    .unwrap_or(true);
4524                if skip {
4525                    continue;
4526                }
4527                stage.insert(
4528                    table_id,
4529                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
4530                );
4531            }
4532            Op::ExternalTableState { name, state } => {
4533                write_external_state_file(root, &name, &state)?;
4534            }
4535            Op::Flush { .. } | Op::TxnCommit { .. } | Op::TxnAbort | Op::Ddl(_) => {}
4536        }
4537    }
4538    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
4539        let Some(handle) = tables.get(&table_id) else {
4540            continue;
4541        };
4542        let mut t = handle.lock();
4543        if let Some(epoch) = truncate_epoch {
4544            t.apply_truncate(epoch)?;
4545        }
4546        t.recover_apply(rows, deletes)?;
4547        if truncate_epoch.is_some() {
4548            let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
4549            t.live_count = rows.len() as u64;
4550            t.persist_manifest(table_epoch)?;
4551        }
4552    }
4553
4554    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
4555    // between TxnCommit sync and the publish phase leaves the run in
4556    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
4557    for (txn_id, ce, added_runs) in &spilled_to_link {
4558        for ar in added_runs {
4559            let Some(handle) = tables.get(&ar.table_id) else {
4560                continue;
4561            };
4562            let mut t = handle.lock();
4563            let dest = t.run_path(ar.run_id as u64);
4564            if !dest.exists() {
4565                let pending = root
4566                    .join(TABLES_DIR)
4567                    .join(ar.table_id.to_string())
4568                    .join("_txn")
4569                    .join(txn_id.to_string())
4570                    .join(format!("r-{}.sr", ar.run_id));
4571                if pending.exists() {
4572                    if let Some(parent) = pending.parent() {
4573                        std::fs::rename(&pending, &dest)?;
4574                        let _ = std::fs::remove_dir_all(parent);
4575                    }
4576                }
4577            }
4578            // Only link a run whose file is actually present, and never re-link
4579            // one the publish phase already persisted into the manifest (which is
4580            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
4581            // until segment GC). `recover_spilled_run` is idempotent + reconciles
4582            // `live_count`/indexes only when the run is genuinely new.
4583            if t.run_path(ar.run_id as u64).exists() {
4584                t.recover_spilled_run(crate::manifest::RunRef {
4585                    run_id: ar.run_id,
4586                    level: ar.level,
4587                    epoch_created: *ce,
4588                    row_count: ar.row_count,
4589                });
4590            }
4591        }
4592    }
4593
4594    epoch.advance_recovered(Epoch(max_epoch));
4595    Ok(())
4596}
4597
4598fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
4599    match condition {
4600        ProcedureCondition::Pk { .. } => {
4601            if schema.primary_key().is_none() {
4602                return Err(MongrelError::InvalidArgument(
4603                    "procedure condition Pk references a table without a primary key".into(),
4604                ));
4605            }
4606        }
4607        ProcedureCondition::BitmapEq { column_id, .. }
4608        | ProcedureCondition::BitmapIn { column_id, .. }
4609        | ProcedureCondition::Range { column_id, .. }
4610        | ProcedureCondition::RangeF64 { column_id, .. }
4611        | ProcedureCondition::IsNull { column_id }
4612        | ProcedureCondition::IsNotNull { column_id }
4613        | ProcedureCondition::FmContains { column_id, .. } => {
4614            validate_column_id(*column_id, schema)?;
4615        }
4616    }
4617    Ok(())
4618}
4619
4620fn bind_procedure_args(
4621    procedure: &StoredProcedure,
4622    mut args: HashMap<String, crate::Value>,
4623) -> Result<HashMap<String, crate::Value>> {
4624    let mut out = HashMap::new();
4625    for param in &procedure.params {
4626        let value = match args.remove(&param.name) {
4627            Some(value) => value,
4628            None => param.default.clone().ok_or_else(|| {
4629                MongrelError::InvalidArgument(format!(
4630                    "missing required procedure parameter {:?}",
4631                    param.name
4632                ))
4633            })?,
4634        };
4635        if !param.nullable && matches!(value, crate::Value::Null) {
4636            return Err(MongrelError::InvalidArgument(format!(
4637                "procedure parameter {:?} must not be NULL",
4638                param.name
4639            )));
4640        }
4641        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty) {
4642            return Err(MongrelError::InvalidArgument(format!(
4643                "procedure parameter {:?} has wrong type",
4644                param.name
4645            )));
4646        }
4647        out.insert(param.name.clone(), value);
4648    }
4649    if let Some(extra) = args.keys().next() {
4650        return Err(MongrelError::InvalidArgument(format!(
4651            "unknown procedure parameter {extra:?}"
4652        )));
4653    }
4654    Ok(out)
4655}
4656
4657fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
4658    matches!(
4659        (value, ty),
4660        (crate::Value::Bool(_), crate::TypeId::Bool)
4661            | (crate::Value::Int64(_), crate::TypeId::Int8)
4662            | (crate::Value::Int64(_), crate::TypeId::Int16)
4663            | (crate::Value::Int64(_), crate::TypeId::Int32)
4664            | (crate::Value::Int64(_), crate::TypeId::Int64)
4665            | (crate::Value::Int64(_), crate::TypeId::UInt8)
4666            | (crate::Value::Int64(_), crate::TypeId::UInt16)
4667            | (crate::Value::Int64(_), crate::TypeId::UInt32)
4668            | (crate::Value::Int64(_), crate::TypeId::UInt64)
4669            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
4670            | (crate::Value::Int64(_), crate::TypeId::Date32)
4671            | (crate::Value::Float64(_), crate::TypeId::Float32)
4672            | (crate::Value::Float64(_), crate::TypeId::Float64)
4673            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
4674            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
4675    )
4676}
4677
4678fn eval_cells(
4679    cells: &[crate::procedure::ProcedureCell],
4680    args: &HashMap<String, crate::Value>,
4681    outputs: &HashMap<String, ProcedureCallOutput>,
4682) -> Result<Vec<(u16, crate::Value)>> {
4683    cells
4684        .iter()
4685        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
4686        .collect()
4687}
4688
4689fn eval_condition(
4690    condition: &ProcedureCondition,
4691    args: &HashMap<String, crate::Value>,
4692    outputs: &HashMap<String, ProcedureCallOutput>,
4693) -> Result<crate::Condition> {
4694    Ok(match condition {
4695        ProcedureCondition::Pk { value } => {
4696            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
4697        }
4698        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
4699            column_id: *column_id,
4700            value: eval_value(value, args, outputs)?.encode_key(),
4701        },
4702        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
4703            column_id: *column_id,
4704            values: values
4705                .iter()
4706                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
4707                .collect::<Result<Vec<_>>>()?,
4708        },
4709        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
4710            column_id: *column_id,
4711            lo: expect_i64(eval_value(lo, args, outputs)?)?,
4712            hi: expect_i64(eval_value(hi, args, outputs)?)?,
4713        },
4714        ProcedureCondition::RangeF64 {
4715            column_id,
4716            lo,
4717            lo_inclusive,
4718            hi,
4719            hi_inclusive,
4720        } => crate::Condition::RangeF64 {
4721            column_id: *column_id,
4722            lo: expect_f64(eval_value(lo, args, outputs)?)?,
4723            lo_inclusive: *lo_inclusive,
4724            hi: expect_f64(eval_value(hi, args, outputs)?)?,
4725            hi_inclusive: *hi_inclusive,
4726        },
4727        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
4728            column_id: *column_id,
4729        },
4730        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
4731            column_id: *column_id,
4732        },
4733        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
4734            column_id: *column_id,
4735            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
4736        },
4737    })
4738}
4739
4740fn eval_value(
4741    value: &ProcedureValue,
4742    args: &HashMap<String, crate::Value>,
4743    outputs: &HashMap<String, ProcedureCallOutput>,
4744) -> Result<crate::Value> {
4745    match value {
4746        ProcedureValue::Literal(value) => Ok(value.clone()),
4747        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
4748            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
4749        }),
4750        ProcedureValue::StepScalar(id) => match outputs.get(id) {
4751            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
4752            _ => Err(MongrelError::InvalidArgument(format!(
4753                "procedure step {id:?} did not return a scalar"
4754            ))),
4755        },
4756        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
4757            Err(MongrelError::InvalidArgument(
4758                "row-valued procedure reference cannot be used as a scalar".into(),
4759            ))
4760        }
4761        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
4762            "structured procedure value cannot be used as a scalar cell".into(),
4763        )),
4764    }
4765}
4766
4767fn eval_return_output(
4768    value: &ProcedureValue,
4769    args: &HashMap<String, crate::Value>,
4770    outputs: &HashMap<String, ProcedureCallOutput>,
4771) -> Result<ProcedureCallOutput> {
4772    match value {
4773        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
4774        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
4775            args.get(name).cloned().ok_or_else(|| {
4776                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
4777            })?,
4778        )),
4779        ProcedureValue::StepRows(id)
4780        | ProcedureValue::StepRow(id)
4781        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
4782            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
4783        }),
4784        ProcedureValue::Object(fields) => {
4785            let mut out = Vec::with_capacity(fields.len());
4786            for (name, value) in fields {
4787                out.push((name.clone(), eval_return_output(value, args, outputs)?));
4788            }
4789            Ok(ProcedureCallOutput::Object(out))
4790        }
4791        ProcedureValue::Array(values) => {
4792            let mut out = Vec::with_capacity(values.len());
4793            for value in values {
4794                out.push(eval_return_output(value, args, outputs)?);
4795            }
4796            Ok(ProcedureCallOutput::Array(out))
4797        }
4798    }
4799}
4800
4801fn expect_i64(value: crate::Value) -> Result<i64> {
4802    match value {
4803        crate::Value::Int64(value) => Ok(value),
4804        _ => Err(MongrelError::InvalidArgument(
4805            "procedure value must be Int64".into(),
4806        )),
4807    }
4808}
4809
4810fn expect_f64(value: crate::Value) -> Result<f64> {
4811    match value {
4812        crate::Value::Float64(value) => Ok(value),
4813        _ => Err(MongrelError::InvalidArgument(
4814            "procedure value must be Float64".into(),
4815        )),
4816    }
4817}
4818
4819fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
4820    match value {
4821        crate::Value::Bytes(value) => Ok(value),
4822        _ => Err(MongrelError::InvalidArgument(
4823            "procedure value must be Bytes".into(),
4824        )),
4825    }
4826}
4827
4828fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
4829    if schema.columns.iter().any(|c| c.id == column_id) {
4830        Ok(())
4831    } else {
4832        Err(MongrelError::InvalidArgument(format!(
4833            "unknown column id {column_id}"
4834        )))
4835    }
4836}
4837
4838fn trigger_matches_event(
4839    trigger: &StoredTrigger,
4840    event: &WriteEvent,
4841    cat: &Catalog,
4842) -> Result<bool> {
4843    if trigger.event != event.kind {
4844        return Ok(false);
4845    }
4846    let TriggerTarget::Table(target) = &trigger.target else {
4847        return Ok(false);
4848    };
4849    if target != &event.table {
4850        return Ok(false);
4851    }
4852    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
4853        let schema = &cat
4854            .live(target)
4855            .ok_or_else(|| {
4856                MongrelError::InvalidArgument(format!(
4857                    "trigger {:?} references unknown table {target:?}",
4858                    trigger.name
4859                ))
4860            })?
4861            .schema;
4862        let mut watched = Vec::with_capacity(trigger.update_of.len());
4863        for name in &trigger.update_of {
4864            let col = schema.column(name).ok_or_else(|| {
4865                MongrelError::InvalidArgument(format!(
4866                    "trigger {:?} references unknown UPDATE OF column {name:?}",
4867                    trigger.name
4868                ))
4869            })?;
4870            watched.push(col.id);
4871        }
4872        if !event
4873            .changed_columns
4874            .iter()
4875            .any(|column_id| watched.contains(column_id))
4876        {
4877            return Ok(false);
4878        }
4879    }
4880    Ok(true)
4881}
4882
4883fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
4884    let mut ids = std::collections::BTreeSet::new();
4885    if let Some(old) = old {
4886        ids.extend(old.columns.keys().copied());
4887    }
4888    if let Some(new) = new {
4889        ids.extend(new.columns.keys().copied());
4890    }
4891    ids.into_iter()
4892        .filter(|id| {
4893            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
4894        })
4895        .collect()
4896}
4897
4898fn eval_trigger_cells(
4899    cells: &[crate::trigger::TriggerCell],
4900    event: &WriteEvent,
4901    selected: Option<&TriggerRowImage>,
4902) -> Result<Vec<(u16, Value)>> {
4903    cells
4904        .iter()
4905        .map(|cell| {
4906            Ok((
4907                cell.column_id,
4908                eval_trigger_value(&cell.value, event, selected)?,
4909            ))
4910        })
4911        .collect()
4912}
4913
4914fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
4915    match expr {
4916        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
4917            Value::Bool(value) => Ok(value),
4918            Value::Null => Ok(false),
4919            other => Err(MongrelError::InvalidArgument(format!(
4920                "trigger WHEN value must be boolean, got {other:?}"
4921            ))),
4922        },
4923        TriggerExpr::Eq { left, right } => Ok(values_equal(
4924            &eval_trigger_value(left, event, None)?,
4925            &eval_trigger_value(right, event, None)?,
4926        )),
4927        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
4928            &eval_trigger_value(left, event, None)?,
4929            &eval_trigger_value(right, event, None)?,
4930        )),
4931        TriggerExpr::Lt { left, right } => match value_order(
4932            &eval_trigger_value(left, event, None)?,
4933            &eval_trigger_value(right, event, None)?,
4934        ) {
4935            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
4936            None => Ok(false),
4937        },
4938        TriggerExpr::Lte { left, right } => match value_order(
4939            &eval_trigger_value(left, event, None)?,
4940            &eval_trigger_value(right, event, None)?,
4941        ) {
4942            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
4943            None => Ok(false),
4944        },
4945        TriggerExpr::Gt { left, right } => match value_order(
4946            &eval_trigger_value(left, event, None)?,
4947            &eval_trigger_value(right, event, None)?,
4948        ) {
4949            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
4950            None => Ok(false),
4951        },
4952        TriggerExpr::Gte { left, right } => match value_order(
4953            &eval_trigger_value(left, event, None)?,
4954            &eval_trigger_value(right, event, None)?,
4955        ) {
4956            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
4957            None => Ok(false),
4958        },
4959        TriggerExpr::IsNull(value) => Ok(matches!(
4960            eval_trigger_value(value, event, None)?,
4961            Value::Null
4962        )),
4963        TriggerExpr::IsNotNull(value) => Ok(!matches!(
4964            eval_trigger_value(value, event, None)?,
4965            Value::Null
4966        )),
4967        TriggerExpr::And { left, right } => {
4968            if !eval_trigger_expr(left, event)? {
4969                Ok(false)
4970            } else {
4971                Ok(eval_trigger_expr(right, event)?)
4972            }
4973        }
4974        TriggerExpr::Or { left, right } => {
4975            if eval_trigger_expr(left, event)? {
4976                Ok(true)
4977            } else {
4978                Ok(eval_trigger_expr(right, event)?)
4979            }
4980        }
4981        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
4982    }
4983}
4984
4985fn eval_trigger_condition(
4986    condition: &TriggerCondition,
4987    event: &WriteEvent,
4988    selected: &TriggerRowImage,
4989    schema: &Schema,
4990) -> Result<bool> {
4991    match condition {
4992        TriggerCondition::Pk { value } => {
4993            let pk = schema.primary_key().ok_or_else(|| {
4994                MongrelError::InvalidArgument(
4995                    "trigger condition Pk references a table without a primary key".into(),
4996                )
4997            })?;
4998            let lhs = eval_trigger_value(value, event, Some(selected))?;
4999            Ok(values_equal(
5000                &lhs,
5001                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
5002            ))
5003        }
5004        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
5005            selected.columns.get(column_id).unwrap_or(&Value::Null),
5006            &eval_trigger_value(value, event, Some(selected))?,
5007        )),
5008        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
5009            selected.columns.get(column_id).unwrap_or(&Value::Null),
5010            &eval_trigger_value(value, event, Some(selected))?,
5011        )),
5012        TriggerCondition::Lt { column_id, value } => match value_order(
5013            selected.columns.get(column_id).unwrap_or(&Value::Null),
5014            &eval_trigger_value(value, event, Some(selected))?,
5015        ) {
5016            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
5017            None => Ok(false),
5018        },
5019        TriggerCondition::Lte { column_id, value } => match value_order(
5020            selected.columns.get(column_id).unwrap_or(&Value::Null),
5021            &eval_trigger_value(value, event, Some(selected))?,
5022        ) {
5023            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
5024            None => Ok(false),
5025        },
5026        TriggerCondition::Gt { column_id, value } => match value_order(
5027            selected.columns.get(column_id).unwrap_or(&Value::Null),
5028            &eval_trigger_value(value, event, Some(selected))?,
5029        ) {
5030            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
5031            None => Ok(false),
5032        },
5033        TriggerCondition::Gte { column_id, value } => match value_order(
5034            selected.columns.get(column_id).unwrap_or(&Value::Null),
5035            &eval_trigger_value(value, event, Some(selected))?,
5036        ) {
5037            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
5038            None => Ok(false),
5039        },
5040        TriggerCondition::IsNull { column_id } => Ok(matches!(
5041            selected.columns.get(column_id),
5042            None | Some(Value::Null)
5043        )),
5044        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
5045            selected.columns.get(column_id),
5046            None | Some(Value::Null)
5047        )),
5048        TriggerCondition::And { left, right } => {
5049            if !eval_trigger_condition(left, event, selected, schema)? {
5050                Ok(false)
5051            } else {
5052                Ok(eval_trigger_condition(right, event, selected, schema)?)
5053            }
5054        }
5055        TriggerCondition::Or { left, right } => {
5056            if eval_trigger_condition(left, event, selected, schema)? {
5057                Ok(true)
5058            } else {
5059                Ok(eval_trigger_condition(right, event, selected, schema)?)
5060            }
5061        }
5062        TriggerCondition::Not(condition) => {
5063            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
5064        }
5065    }
5066}
5067
5068fn eval_trigger_value(
5069    value: &TriggerValue,
5070    event: &WriteEvent,
5071    selected: Option<&TriggerRowImage>,
5072) -> Result<Value> {
5073    match value {
5074        TriggerValue::Literal(value) => Ok(value.clone()),
5075        TriggerValue::NewColumn(column_id) => event
5076            .new
5077            .as_ref()
5078            .and_then(|row| row.columns.get(column_id))
5079            .cloned()
5080            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
5081        TriggerValue::OldColumn(column_id) => event
5082            .old
5083            .as_ref()
5084            .and_then(|row| row.columns.get(column_id))
5085            .cloned()
5086            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
5087        TriggerValue::SelectedColumn(column_id) => selected
5088            .and_then(|row| row.columns.get(column_id))
5089            .cloned()
5090            .ok_or_else(|| {
5091                MongrelError::InvalidArgument("SELECTED column is not available".into())
5092            }),
5093    }
5094}
5095
5096fn values_equal(left: &Value, right: &Value) -> bool {
5097    match (left, right) {
5098        (Value::Null, Value::Null) => true,
5099        (Value::Bool(a), Value::Bool(b)) => a == b,
5100        (Value::Int64(a), Value::Int64(b)) => a == b,
5101        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
5102        (Value::Bytes(a), Value::Bytes(b)) => a == b,
5103        (Value::Embedding(a), Value::Embedding(b)) => {
5104            a.len() == b.len()
5105                && a.iter()
5106                    .zip(b.iter())
5107                    .all(|(a, b)| a.to_bits() == b.to_bits())
5108        }
5109        _ => false,
5110    }
5111}
5112
5113fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
5114    match (left, right) {
5115        (Value::Null, _) | (_, Value::Null) => None,
5116        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
5117        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
5118        // Cross-type Int64/Float64 comparison coerces the integer to f64.
5119        // This matches the spec but can lose precision for i64 values above 2^53.
5120        (Value::Int64(a), Value::Float64(b)) => {
5121            let af = *a as f64;
5122            Some(af.total_cmp(b))
5123        }
5124        // Cross-type Int64/Float64 comparison coerces the integer to f64.
5125        // This matches the spec but can lose precision for i64 values above 2^53.
5126        (Value::Float64(a), Value::Int64(b)) => {
5127            let bf = *b as f64;
5128            Some(a.total_cmp(&bf))
5129        }
5130        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
5131        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
5132        (Value::Embedding(_), Value::Embedding(_)) => None,
5133        _ => None,
5134    }
5135}
5136
5137fn trigger_message(value: Value) -> String {
5138    match value {
5139        Value::Null => "NULL".into(),
5140        Value::Bool(value) => value.to_string(),
5141        Value::Int64(value) => value.to_string(),
5142        Value::Float64(value) => value.to_string(),
5143        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
5144        Value::Embedding(value) => format!("{value:?}"),
5145        Value::Decimal(value) => value.to_string(),
5146        Value::Interval {
5147            months,
5148            days,
5149            nanos,
5150        } => format!("{months}m {days}d {nanos}ns"),
5151        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
5152        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
5153    }
5154}
5155
5156fn validate_trigger_step<'a>(
5157    step: &TriggerStep,
5158    cat: &'a Catalog,
5159    target_schema: &Schema,
5160    event: TriggerEvent,
5161    select_schemas: &mut HashMap<String, &'a Schema>,
5162) -> Result<()> {
5163    match step {
5164        TriggerStep::SetNew { cells } => {
5165            if event == TriggerEvent::Delete {
5166                return Err(MongrelError::InvalidArgument(
5167                    "SetNew trigger step is not valid for DELETE triggers".into(),
5168                ));
5169            }
5170            for cell in cells {
5171                validate_column_id(cell.column_id, target_schema)?;
5172                validate_trigger_value(&cell.value, target_schema, event)?;
5173            }
5174        }
5175        TriggerStep::Insert { table, cells } => {
5176            let schema = trigger_write_schema(cat, table, "insert")?;
5177            for cell in cells {
5178                validate_column_id(cell.column_id, schema)?;
5179                validate_trigger_value(&cell.value, target_schema, event)?;
5180            }
5181        }
5182        TriggerStep::UpdateByPk { table, pk, cells } => {
5183            let schema = trigger_write_schema(cat, table, "update")?;
5184            if schema.primary_key().is_none() {
5185                return Err(MongrelError::InvalidArgument(format!(
5186                    "trigger update_by_pk references table {table:?} without a primary key"
5187                )));
5188            }
5189            validate_trigger_value(pk, target_schema, event)?;
5190            for cell in cells {
5191                validate_column_id(cell.column_id, schema)?;
5192                validate_trigger_value(&cell.value, target_schema, event)?;
5193            }
5194        }
5195        TriggerStep::DeleteByPk { table, pk } => {
5196            let schema = trigger_write_schema(cat, table, "delete")?;
5197            if schema.primary_key().is_none() {
5198                return Err(MongrelError::InvalidArgument(format!(
5199                    "trigger delete_by_pk references table {table:?} without a primary key"
5200                )));
5201            }
5202            validate_trigger_value(pk, target_schema, event)?;
5203        }
5204        TriggerStep::Select {
5205            id,
5206            table,
5207            conditions,
5208        } => {
5209            let schema = trigger_read_schema(cat, table)?;
5210            for condition in conditions {
5211                validate_trigger_condition(condition, schema, target_schema, event)?;
5212            }
5213            if select_schemas.contains_key(id) {
5214                return Err(MongrelError::InvalidArgument(format!(
5215                    "duplicate select id {id:?} in trigger program"
5216                )));
5217            }
5218            select_schemas.insert(id.clone(), schema);
5219        }
5220        TriggerStep::Foreach { id, steps } => {
5221            if !select_schemas.contains_key(id) {
5222                return Err(MongrelError::InvalidArgument(format!(
5223                    "foreach references unknown select id {id:?}"
5224                )));
5225            }
5226            let mut inner_select_schemas = select_schemas.clone();
5227            for step in steps {
5228                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
5229            }
5230        }
5231        TriggerStep::DeleteWhere { table, conditions } => {
5232            let schema = trigger_write_schema(cat, table, "delete")?;
5233            for condition in conditions {
5234                validate_trigger_condition(condition, schema, target_schema, event)?;
5235            }
5236        }
5237        TriggerStep::UpdateWhere {
5238            table,
5239            conditions,
5240            cells,
5241        } => {
5242            let schema = trigger_write_schema(cat, table, "update")?;
5243            for condition in conditions {
5244                validate_trigger_condition(condition, schema, target_schema, event)?;
5245            }
5246            for cell in cells {
5247                validate_column_id(cell.column_id, schema)?;
5248                validate_trigger_value(&cell.value, target_schema, event)?;
5249            }
5250        }
5251        TriggerStep::Raise { message, .. } => {
5252            validate_trigger_value(message, target_schema, event)?
5253        }
5254    }
5255    Ok(())
5256}
5257
5258fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
5259    if let Some(entry) = cat.live(table) {
5260        return Ok(&entry.schema);
5261    }
5262    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
5263        let allowed = match op {
5264            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
5265            "update" | "delete" => entry.capabilities.writable,
5266            _ => false,
5267        };
5268        if !allowed {
5269            return Err(MongrelError::InvalidArgument(format!(
5270                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
5271                entry.module
5272            )));
5273        }
5274        if !entry.capabilities.transaction_safe {
5275            return Err(MongrelError::InvalidArgument(format!(
5276                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
5277                entry.module
5278            )));
5279        }
5280        return Ok(&entry.declared_schema);
5281    }
5282    Err(MongrelError::InvalidArgument(format!(
5283        "trigger references unknown table {table:?}"
5284    )))
5285}
5286
5287fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
5288    if let Some(entry) = cat.live(table) {
5289        return Ok(&entry.schema);
5290    }
5291    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
5292        if entry.capabilities.trigger_safe {
5293            return Ok(&entry.declared_schema);
5294        }
5295        return Err(MongrelError::InvalidArgument(format!(
5296            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
5297            entry.module
5298        )));
5299    }
5300    Err(MongrelError::InvalidArgument(format!(
5301        "trigger references unknown table {table:?}"
5302    )))
5303}
5304
5305fn validate_trigger_condition(
5306    condition: &TriggerCondition,
5307    schema: &Schema,
5308    target_schema: &Schema,
5309    event: TriggerEvent,
5310) -> Result<()> {
5311    match condition {
5312        TriggerCondition::Pk { value } => {
5313            if schema.primary_key().is_none() {
5314                return Err(MongrelError::InvalidArgument(
5315                    "trigger condition Pk references a table without a primary key".into(),
5316                ));
5317            }
5318            validate_trigger_value(value, target_schema, event)
5319        }
5320        TriggerCondition::Eq { column_id, value }
5321        | TriggerCondition::NotEq { column_id, value }
5322        | TriggerCondition::Lt { column_id, value }
5323        | TriggerCondition::Lte { column_id, value }
5324        | TriggerCondition::Gt { column_id, value }
5325        | TriggerCondition::Gte { column_id, value } => {
5326            validate_column_id(*column_id, schema)?;
5327            validate_trigger_value(value, target_schema, event)
5328        }
5329        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
5330            validate_column_id(*column_id, schema)
5331        }
5332        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
5333            validate_trigger_condition(left, schema, target_schema, event)?;
5334            validate_trigger_condition(right, schema, target_schema, event)
5335        }
5336        TriggerCondition::Not(condition) => {
5337            validate_trigger_condition(condition, schema, target_schema, event)
5338        }
5339    }
5340}
5341
5342fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
5343    match expr {
5344        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
5345            validate_trigger_value(value, schema, event)
5346        }
5347        TriggerExpr::Eq { left, right }
5348        | TriggerExpr::NotEq { left, right }
5349        | TriggerExpr::Lt { left, right }
5350        | TriggerExpr::Lte { left, right }
5351        | TriggerExpr::Gt { left, right }
5352        | TriggerExpr::Gte { left, right } => {
5353            validate_trigger_value(left, schema, event)?;
5354            validate_trigger_value(right, schema, event)
5355        }
5356        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
5357            validate_trigger_expr(left, schema, event)?;
5358            validate_trigger_expr(right, schema, event)
5359        }
5360        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
5361    }
5362}
5363
5364fn validate_trigger_value(
5365    value: &TriggerValue,
5366    schema: &Schema,
5367    event: TriggerEvent,
5368) -> Result<()> {
5369    match value {
5370        TriggerValue::Literal(_) => Ok(()),
5371        TriggerValue::NewColumn(id) => {
5372            if event == TriggerEvent::Delete {
5373                return Err(MongrelError::InvalidArgument(
5374                    "DELETE triggers cannot reference NEW".into(),
5375                ));
5376            }
5377            validate_column_id(*id, schema)
5378        }
5379        TriggerValue::OldColumn(id) => {
5380            if event == TriggerEvent::Insert {
5381                return Err(MongrelError::InvalidArgument(
5382                    "INSERT triggers cannot reference OLD".into(),
5383                ));
5384            }
5385            validate_column_id(*id, schema)
5386        }
5387        // SELECTED column references are only meaningful inside a foreach loop.
5388        // Strict loop-scope validation is deferred to runtime; the executor raises
5389        // an error if a selected row is not available.
5390        TriggerValue::SelectedColumn(_) => Ok(()),
5391    }
5392}
5393
5394/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
5395/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
5396/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
5397/// catalog. This pass closes that window by reconstructing missing entries
5398/// (and marking committed drops) before tables are mounted.
5399fn recover_ddl_from_wal(
5400    root: &Path,
5401    cat: &mut Catalog,
5402    meta_dek: Option<&[u8; META_DEK_LEN]>,
5403    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
5404) -> Result<()> {
5405    use crate::wal::{DdlOp, Op, SharedWal};
5406
5407    let records = match SharedWal::replay_with_dek(root, wal_dek) {
5408        Ok(r) => r,
5409        Err(_) => return Ok(()),
5410    };
5411
5412    let mut committed: HashMap<u64, u64> = HashMap::new();
5413    for r in &records {
5414        if let Op::TxnCommit { epoch: ce, .. } = r.op {
5415            committed.insert(r.txn_id, ce);
5416        }
5417    }
5418
5419    let mut changed = false;
5420    for r in records {
5421        let Some(&ce) = committed.get(&r.txn_id) else {
5422            continue;
5423        };
5424        match r.op {
5425            Op::Ddl(DdlOp::CreateTable {
5426                table_id,
5427                ref name,
5428                ref schema_json,
5429            }) => {
5430                if cat.tables.iter().any(|t| t.table_id == table_id) {
5431                    continue;
5432                }
5433                let schema = DdlOp::decode_schema(schema_json)?;
5434                let tdir = root.join(TABLES_DIR).join(table_id.to_string());
5435                if !tdir.exists() {
5436                    std::fs::create_dir_all(tdir.join(crate::engine::WAL_DIR))?;
5437                    std::fs::create_dir_all(tdir.join(crate::engine::RUNS_DIR))?;
5438                    crate::engine::write_schema(&tdir, &schema)?;
5439                    // The DB-wide meta DEK is also the per-table manifest meta
5440                    // DEK (both derive from the KEK via `derive_meta_key`), so a
5441                    // reconstructed manifest must be sealed with it — otherwise
5442                    // the follow-up `Table::open_in` cannot authenticate it on an
5443                    // encrypted DB and the table becomes permanently unopenable.
5444                    let mut m = crate::manifest::Manifest::new(table_id, schema.schema_id);
5445                    crate::manifest::write_atomic(&tdir, &mut m, meta_dek)?;
5446                }
5447                cat.tables.push(CatalogEntry {
5448                    table_id,
5449                    name: name.clone(),
5450                    schema,
5451                    state: TableState::Live,
5452                    created_epoch: ce,
5453                });
5454                cat.next_table_id = cat.next_table_id.max(table_id + 1);
5455                changed = true;
5456            }
5457            Op::Ddl(DdlOp::DropTable { table_id }) => {
5458                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
5459                    if matches!(entry.state, TableState::Live) {
5460                        entry.state = TableState::Dropped { at_epoch: ce };
5461                        changed = true;
5462                    }
5463                }
5464            }
5465            Op::Ddl(DdlOp::RenameTable {
5466                table_id,
5467                ref new_name,
5468            }) => {
5469                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
5470                    if entry.name != *new_name {
5471                        entry.name = new_name.clone();
5472                        changed = true;
5473                    }
5474                }
5475                // If the entry is absent, its CreateTable was already
5476                // checkpointed carrying the post-rename name, so there is
5477                // nothing to apply — a no-op, not an error.
5478            }
5479            Op::Ddl(DdlOp::AlterTable {
5480                table_id,
5481                ref column_json,
5482            }) => {
5483                let column = DdlOp::decode_column(column_json)?;
5484                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
5485                    if apply_recovered_column_def(&mut entry.schema, column) {
5486                        let tdir = root.join(TABLES_DIR).join(table_id.to_string());
5487                        if tdir.exists() {
5488                            crate::engine::write_schema(&tdir, &entry.schema)?;
5489                        }
5490                        changed = true;
5491                    }
5492                }
5493            }
5494            _ => {}
5495        }
5496    }
5497
5498    if changed {
5499        catalog::write_atomic(root, cat, meta_dek)?;
5500    }
5501    Ok(())
5502}
5503
5504fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> bool {
5505    match schema.columns.iter_mut().find(|c| c.id == column.id) {
5506        Some(existing) if *existing == column => false,
5507        Some(existing) => {
5508            *existing = column;
5509            schema.schema_id = schema.schema_id.saturating_add(1);
5510            true
5511        }
5512        None => {
5513            schema.columns.push(column);
5514            schema.schema_id = schema.schema_id.saturating_add(1);
5515            true
5516        }
5517    }
5518}
5519
5520/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
5521/// #14). These dirs hold pending uniform-epoch runs from large transactions
5522/// that were aborted or crashed before commit. On open, all such dirs are safe
5523/// to remove — committed txns moved their runs to `_runs/` at publish time.
5524fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
5525    for entry in &cat.tables {
5526        let txn_dir = root
5527            .join(TABLES_DIR)
5528            .join(entry.table_id.to_string())
5529            .join("_txn");
5530        if txn_dir.exists() {
5531            let _ = std::fs::remove_dir_all(&txn_dir);
5532        }
5533    }
5534}
5535
5536#[cfg(test)]
5537mod trigger_engine_tests {
5538    use super::*;
5539
5540    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
5541        WriteEvent {
5542            table: "test".into(),
5543            kind: TriggerEvent::Insert,
5544            new: Some(TriggerRowImage {
5545                columns: new_cells.iter().cloned().collect(),
5546            }),
5547            old: Some(TriggerRowImage {
5548                columns: old_cells.iter().cloned().collect(),
5549            }),
5550            changed_columns: Vec::new(),
5551            op_indices: Vec::new(),
5552            put_idx: None,
5553            trigger_stack: Vec::new(),
5554        }
5555    }
5556
5557    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
5558        WriteEvent {
5559            table: "test".into(),
5560            kind: TriggerEvent::Insert,
5561            new: Some(TriggerRowImage {
5562                columns: new_cells.iter().cloned().collect(),
5563            }),
5564            old: None,
5565            changed_columns: Vec::new(),
5566            op_indices: Vec::new(),
5567            put_idx: None,
5568            trigger_stack: Vec::new(),
5569        }
5570    }
5571
5572    #[test]
5573    fn value_order_int64_vs_float64() {
5574        assert_eq!(
5575            value_order(&Value::Int64(5), &Value::Float64(5.0)),
5576            Some(std::cmp::Ordering::Equal)
5577        );
5578        assert_eq!(
5579            value_order(&Value::Int64(5), &Value::Float64(3.0)),
5580            Some(std::cmp::Ordering::Greater)
5581        );
5582        assert_eq!(
5583            value_order(&Value::Int64(2), &Value::Float64(3.0)),
5584            Some(std::cmp::Ordering::Less)
5585        );
5586    }
5587
5588    #[test]
5589    fn value_order_null_returns_none() {
5590        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
5591        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
5592        assert_eq!(value_order(&Value::Null, &Value::Null), None);
5593    }
5594
5595    #[test]
5596    fn value_order_cross_group_returns_none() {
5597        assert_eq!(
5598            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
5599            None
5600        );
5601        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
5602        assert_eq!(
5603            value_order(
5604                &Value::Embedding(vec![1.0, 2.0]),
5605                &Value::Embedding(vec![1.0, 2.0])
5606            ),
5607            None
5608        );
5609    }
5610
5611    #[test]
5612    fn eval_trigger_expr_ranges_and_booleans() {
5613        let expr = TriggerExpr::And {
5614            left: Box::new(TriggerExpr::Gt {
5615                left: TriggerValue::NewColumn(1),
5616                right: TriggerValue::Literal(Value::Int64(0)),
5617            }),
5618            right: Box::new(TriggerExpr::Lte {
5619                left: TriggerValue::NewColumn(1),
5620                right: TriggerValue::Literal(Value::Int64(100)),
5621            }),
5622        };
5623        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
5624        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
5625        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
5626
5627        let or_expr = TriggerExpr::Or {
5628            left: Box::new(TriggerExpr::Lt {
5629                left: TriggerValue::NewColumn(1),
5630                right: TriggerValue::Literal(Value::Int64(0)),
5631            }),
5632            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
5633                TriggerValue::OldColumn(2),
5634            )))),
5635        };
5636        assert!(eval_trigger_expr(
5637            &or_expr,
5638            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
5639        )
5640        .unwrap());
5641        assert!(!eval_trigger_expr(
5642            &or_expr,
5643            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
5644        )
5645        .unwrap());
5646
5647        assert!(eval_trigger_expr(
5648            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
5649            &event_insert(&[])
5650        )
5651        .unwrap());
5652        assert!(!eval_trigger_expr(
5653            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
5654            &event_insert(&[])
5655        )
5656        .unwrap());
5657        assert!(!eval_trigger_expr(
5658            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
5659            &event_insert(&[])
5660        )
5661        .unwrap());
5662    }
5663}