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