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