Skip to main content

mongreldb_core/
database.rs

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