Skip to main content

mongreldb_core/
database.rs

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