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