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}
241
242impl Database {
243    /// Create a fresh plaintext database at `root`.
244    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
245        Self::create_inner(root, None)
246    }
247
248    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
249    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
250    #[cfg(feature = "encryption")]
251    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
252        let root = root.as_ref();
253        std::fs::create_dir_all(root)?;
254        std::fs::create_dir_all(root.join(META_DIR))?;
255        let salt = crate::encryption::random_salt();
256        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
257        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
258        Self::create_inner(root, Some(kek))
259    }
260
261    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
262    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
263    #[cfg(feature = "encryption")]
264    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
265        let root = root.as_ref();
266        std::fs::create_dir_all(root)?;
267        std::fs::create_dir_all(root.join(META_DIR))?;
268        let salt = crate::encryption::random_salt();
269        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
270        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
271        Self::create_inner(root, Some(kek))
272    }
273
274    fn create_inner(
275        root: impl AsRef<Path>,
276        kek: Option<Arc<crate::encryption::Kek>>,
277    ) -> Result<Self> {
278        let root = root.as_ref().to_path_buf();
279        std::fs::create_dir_all(&root)?;
280        std::fs::create_dir_all(root.join(TABLES_DIR))?;
281        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
282        let cat = Catalog::empty();
283        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
284        Self::finish_open(root, cat, kek, meta_dek, false)
285    }
286
287    /// Open an existing plaintext database.
288    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
289        Self::open_inner(root, None, None)
290    }
291
292    /// Open an existing encrypted database with a passphrase.
293    #[cfg(feature = "encryption")]
294    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
295        let root = root.as_ref();
296        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
297            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
298        let mut salt = [0u8; crate::encryption::SALT_LEN];
299        salt.copy_from_slice(&salt_bytes);
300        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
301        Self::open_inner(root, Some(kek), None)
302    }
303
304    /// Open an existing encrypted database using a raw high-entropy key.
305    #[cfg(feature = "encryption")]
306    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
307        let root = root.as_ref();
308        let salt_path = root.join(META_DIR).join(KEYS_FILENAME);
309        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
310            MongrelError::NotFound(format!(
311                "encryption salt file {:?}: {e} (database not encrypted, or corrupted)",
312                salt_path
313            ))
314        })?;
315        if salt_bytes.len() != crate::encryption::SALT_LEN {
316            return Err(MongrelError::InvalidArgument(format!(
317                "salt file is {} bytes, expected {}",
318                salt_bytes.len(),
319                crate::encryption::SALT_LEN
320            )));
321        }
322        let mut salt = [0u8; crate::encryption::SALT_LEN];
323        salt.copy_from_slice(&salt_bytes);
324        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
325        Self::open_inner(root, Some(kek), None)
326    }
327
328    fn open_inner(
329        root: impl AsRef<Path>,
330        kek: Option<Arc<crate::encryption::Kek>>,
331        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
332    ) -> Result<Self> {
333        let root = root.as_ref().to_path_buf();
334        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
335        let cat = catalog::read(&root, meta_dek.as_ref())?
336            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
337        Self::finish_open(root, cat, kek, meta_dek, true)
338    }
339
340    fn finish_open(
341        root: PathBuf,
342        cat: Catalog,
343        kek: Option<Arc<crate::encryption::Kek>>,
344        meta_dek: Option<[u8; META_DEK_LEN]>,
345        existing: bool,
346    ) -> Result<Self> {
347        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
348        let snapshots = Arc::new(SnapshotRegistry::new());
349        let page_cache = Arc::new(crate::cache::Sharded::new(
350            crate::cache::CACHE_SHARDS,
351            || {
352                crate::cache::PageCache::new(
353                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
354                )
355            },
356        ));
357        let decoded_cache = Arc::new(crate::cache::Sharded::new(
358            crate::cache::CACHE_SHARDS,
359            || {
360                crate::cache::DecodedPageCache::new(
361                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
362                )
363            },
364        ));
365        let commit_lock = Arc::new(Mutex::new(()));
366        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
367        let shared_wal = Arc::new(Mutex::new(if existing {
368            crate::wal::SharedWal::open(&root, Epoch(cat.db_epoch), wal_dek.clone())?
369        } else {
370            crate::wal::SharedWal::create_with_dek(&root, Epoch(cat.db_epoch), wal_dek.clone())?
371        }));
372        // Shared write-path state handed to every mounted table so single-table
373        // `put`/`commit` writes route through the one shared WAL, the one group-
374        // commit coordinator, and the one poison flag (B1).
375        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
376        let group = Arc::new(crate::txn::GroupCommit::new(
377            shared_wal.lock().durable_seq(),
378        ));
379        // Final base value is set after the open-generation bump below; tables
380        // only draw ids once the user issues a write (post-open), so the
381        // placeholder is never observed.
382        let txn_ids = Arc::new(Mutex::new(1u64));
383
384        // Recover DDL from the shared WAL BEFORE opening tables (spec §15,
385        // review fix #16). A crash between WAL fsync and the catalog
386        // checkpoint leaves committed DDL durable in the WAL but absent from
387        // the on-disk catalog; replay it here so the table-mounting loop and
388        // data-record recovery see a correct catalog.
389        let mut cat = cat;
390        if existing {
391            recover_ddl_from_wal(&root, &mut cat, meta_dek.as_ref(), wal_dek.as_ref())?;
392        }
393
394        // Open every live table against the shared context. Mounted tables have
395        // no private WAL (B1) — `open_in` just loads the manifest/runs and
396        // advances the shared epoch authority to its manifest epoch, so the
397        // final shared watermark is the max across all tables. All of a mounted
398        // table's committed records are replayed below from the shared WAL.
399        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
400        for entry in &cat.tables {
401            if !matches!(entry.state, TableState::Live) {
402                continue;
403            }
404            let tdir = root.join(TABLES_DIR).join(entry.table_id.to_string());
405            let ctx = SharedCtx {
406                epoch: Arc::clone(&epoch),
407                page_cache: Arc::clone(&page_cache),
408                decoded_cache: Arc::clone(&decoded_cache),
409                snapshots: Arc::clone(&snapshots),
410                kek: kek.clone(),
411                commit_lock: Arc::clone(&commit_lock),
412                shared: Some(crate::engine::SharedWalCtx {
413                    wal: Arc::clone(&shared_wal),
414                    group: Arc::clone(&group),
415                    poisoned: Arc::clone(&poisoned),
416                    txn_ids: Arc::clone(&txn_ids),
417                }),
418            };
419            let t = Table::open_in(&tdir, ctx)?;
420            tables.insert(entry.table_id, Arc::new(Mutex::new(t)));
421        }
422
423        // Recover transaction writes from the shared WAL (spec §15). This is the
424        // single durability source for mounted tables: it applies every committed
425        // record — both single-table `Table::commit` writes and cross-table
426        // transactions — gated by each table's `flushed_epoch` (records already
427        // durable in a run are not re-applied).
428        if existing {
429            recover_shared_wal(&root, &tables, &epoch, wal_dek.as_ref())?;
430            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
431            // large transactions (spec §8.5, review fix #14).
432            sweep_pending_txn_dirs(&root, &cat);
433        }
434
435        // Bump `open_generation` on every open and scope transaction ids by it
436        // (`txn_id = (generation << 32) | counter`), so ids never alias across
437        // reopens (review fix #11). Persist the bumped generation to the catalog.
438        if existing {
439            cat.open_generation = cat.open_generation.wrapping_add(1);
440            catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
441        }
442        let next_txn_id = (cat.open_generation << 32) | 1;
443        // Seed the shared txn-id allocator now that the generation is final.
444        *txn_ids.lock() = next_txn_id;
445
446        Ok(Self {
447            root,
448            catalog: RwLock::new(cat),
449            epoch,
450            snapshots,
451            page_cache,
452            decoded_cache,
453            commit_lock,
454            shared_wal,
455            next_txn_id: txn_ids,
456            tables: RwLock::new(tables),
457            kek,
458            ddl_lock: Mutex::new(()),
459            meta_dek,
460            conflicts: crate::txn::ConflictIndex::new(),
461            active_txns: crate::txn::ActiveTxns::new(),
462            poisoned,
463            group,
464            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
465            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
466            spill_hook: Mutex::new(None),
467            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
468            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
469        })
470    }
471
472    /// The current reader-visible epoch.
473    pub fn visible_epoch(&self) -> Epoch {
474        self.epoch.visible()
475    }
476
477    /// Clone the in-memory catalog (for diagnostics / tests).
478    pub fn catalog_snapshot(&self) -> Catalog {
479        self.catalog.read().clone()
480    }
481
482    /// The filesystem root this database was opened/created at.
483    pub fn root(&self) -> &Path {
484        &self.root
485    }
486
487    /// Resolve a table name → id (live tables only). pub(crate) so the
488    /// transaction layer can stage by name.
489    pub fn table_id(&self, name: &str) -> Result<u64> {
490        let cat = self.catalog.read();
491        cat.live(name)
492            .map(|e| e.table_id)
493            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
494    }
495
496    pub fn procedures(&self) -> Vec<StoredProcedure> {
497        self.catalog
498            .read()
499            .procedures
500            .iter()
501            .map(|p| p.procedure.clone())
502            .collect()
503    }
504
505    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
506        self.catalog
507            .read()
508            .procedures
509            .iter()
510            .find(|p| p.procedure.name == name)
511            .map(|p| p.procedure.clone())
512    }
513
514    pub fn create_procedure(&self, mut procedure: StoredProcedure) -> Result<StoredProcedure> {
515        let _g = self.ddl_lock.lock();
516        procedure.validate()?;
517        self.validate_procedure_references(&procedure)?;
518        {
519            let cat = self.catalog.read();
520            if cat
521                .procedures
522                .iter()
523                .any(|p| p.procedure.name == procedure.name)
524            {
525                return Err(MongrelError::InvalidArgument(format!(
526                    "procedure {:?} already exists",
527                    procedure.name
528                )));
529            }
530        }
531        let commit_lock = Arc::clone(&self.commit_lock);
532        let _c = commit_lock.lock();
533        let epoch = self.epoch.bump_assigned();
534        procedure.created_epoch = epoch.0;
535        procedure.updated_epoch = epoch.0;
536        {
537            let mut cat = self.catalog.write();
538            cat.procedures.push(ProcedureEntry::from(procedure.clone()));
539            cat.db_epoch = epoch.0;
540        }
541        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
542        self.advance_visible(epoch);
543        Ok(procedure)
544    }
545
546    pub fn create_or_replace_procedure(
547        &self,
548        procedure: StoredProcedure,
549    ) -> Result<StoredProcedure> {
550        let _g = self.ddl_lock.lock();
551        procedure.validate()?;
552        self.validate_procedure_references(&procedure)?;
553        let commit_lock = Arc::clone(&self.commit_lock);
554        let _c = commit_lock.lock();
555        let epoch = self.epoch.bump_assigned();
556        let replaced = {
557            let mut cat = self.catalog.write();
558            let next = match cat
559                .procedures
560                .iter()
561                .position(|p| p.procedure.name == procedure.name)
562            {
563                Some(idx) => {
564                    let next = cat.procedures[idx]
565                        .procedure
566                        .replaced(procedure.clone(), epoch.0)?;
567                    cat.procedures[idx] = ProcedureEntry::from(next.clone());
568                    next
569                }
570                None => {
571                    let mut next = procedure;
572                    next.created_epoch = epoch.0;
573                    next.updated_epoch = epoch.0;
574                    cat.procedures.push(ProcedureEntry::from(next.clone()));
575                    next
576                }
577            };
578            cat.db_epoch = epoch.0;
579            next
580        };
581        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
582        self.advance_visible(epoch);
583        Ok(replaced)
584    }
585
586    pub fn drop_procedure(&self, name: &str) -> Result<()> {
587        let _g = self.ddl_lock.lock();
588        let commit_lock = Arc::clone(&self.commit_lock);
589        let _c = commit_lock.lock();
590        let epoch = self.epoch.bump_assigned();
591        {
592            let mut cat = self.catalog.write();
593            let before = cat.procedures.len();
594            cat.procedures.retain(|p| p.procedure.name != name);
595            if cat.procedures.len() == before {
596                return Err(MongrelError::NotFound(format!(
597                    "procedure {name:?} not found"
598                )));
599            }
600            cat.db_epoch = epoch.0;
601        }
602        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
603        self.advance_visible(epoch);
604        Ok(())
605    }
606
607    pub fn triggers(&self) -> Vec<StoredTrigger> {
608        self.catalog
609            .read()
610            .triggers
611            .iter()
612            .map(|t| t.trigger.clone())
613            .collect()
614    }
615
616    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
617        self.catalog
618            .read()
619            .triggers
620            .iter()
621            .find(|t| t.trigger.name == name)
622            .map(|t| t.trigger.clone())
623    }
624
625    pub fn create_trigger(&self, mut trigger: StoredTrigger) -> Result<StoredTrigger> {
626        let _g = self.ddl_lock.lock();
627        trigger.validate()?;
628        self.validate_trigger_references(&trigger)?;
629        {
630            let cat = self.catalog.read();
631            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
632                return Err(MongrelError::InvalidArgument(format!(
633                    "trigger {:?} already exists",
634                    trigger.name
635                )));
636            }
637        }
638        let commit_lock = Arc::clone(&self.commit_lock);
639        let _c = commit_lock.lock();
640        let epoch = self.epoch.bump_assigned();
641        trigger.created_epoch = epoch.0;
642        trigger.updated_epoch = epoch.0;
643        {
644            let mut cat = self.catalog.write();
645            cat.triggers.push(TriggerEntry::from(trigger.clone()));
646            cat.db_epoch = epoch.0;
647        }
648        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
649        self.advance_visible(epoch);
650        Ok(trigger)
651    }
652
653    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
654        let _g = self.ddl_lock.lock();
655        trigger.validate()?;
656        self.validate_trigger_references(&trigger)?;
657        let commit_lock = Arc::clone(&self.commit_lock);
658        let _c = commit_lock.lock();
659        let epoch = self.epoch.bump_assigned();
660        let replaced = {
661            let mut cat = self.catalog.write();
662            let next = match cat
663                .triggers
664                .iter()
665                .position(|t| t.trigger.name == trigger.name)
666            {
667                Some(idx) => {
668                    let next = cat.triggers[idx]
669                        .trigger
670                        .replaced(trigger.clone(), epoch.0)?;
671                    cat.triggers[idx] = TriggerEntry::from(next.clone());
672                    next
673                }
674                None => {
675                    let mut next = trigger;
676                    next.created_epoch = epoch.0;
677                    next.updated_epoch = epoch.0;
678                    cat.triggers.push(TriggerEntry::from(next.clone()));
679                    next
680                }
681            };
682            cat.db_epoch = epoch.0;
683            next
684        };
685        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
686        self.advance_visible(epoch);
687        Ok(replaced)
688    }
689
690    pub fn drop_trigger(&self, name: &str) -> Result<()> {
691        let _g = self.ddl_lock.lock();
692        let commit_lock = Arc::clone(&self.commit_lock);
693        let _c = commit_lock.lock();
694        let epoch = self.epoch.bump_assigned();
695        {
696            let mut cat = self.catalog.write();
697            let before = cat.triggers.len();
698            cat.triggers.retain(|t| t.trigger.name != name);
699            if cat.triggers.len() == before {
700                return Err(MongrelError::NotFound(format!(
701                    "trigger {name:?} not found"
702                )));
703            }
704            cat.db_epoch = epoch.0;
705        }
706        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
707        self.advance_visible(epoch);
708        Ok(())
709    }
710
711    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
712        self.catalog.read().external_tables.clone()
713    }
714
715    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
716        self.catalog
717            .read()
718            .external_tables
719            .iter()
720            .find(|t| t.name == name)
721            .cloned()
722    }
723
724    pub fn create_external_table(
725        &self,
726        mut entry: ExternalTableEntry,
727    ) -> Result<ExternalTableEntry> {
728        let _g = self.ddl_lock.lock();
729        entry.validate()?;
730        {
731            let cat = self.catalog.read();
732            if cat.live(&entry.name).is_some()
733                || cat.external_tables.iter().any(|t| t.name == entry.name)
734            {
735                return Err(MongrelError::InvalidArgument(format!(
736                    "table {:?} already exists",
737                    entry.name
738                )));
739            }
740        }
741        let commit_lock = Arc::clone(&self.commit_lock);
742        let _c = commit_lock.lock();
743        let epoch = self.epoch.bump_assigned();
744        entry.created_epoch = epoch.0;
745        {
746            let mut cat = self.catalog.write();
747            cat.external_tables.push(entry.clone());
748            cat.db_epoch = epoch.0;
749        }
750        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
751        self.advance_visible(epoch);
752        Ok(entry)
753    }
754
755    pub fn drop_external_table(&self, name: &str) -> Result<()> {
756        let _g = self.ddl_lock.lock();
757        let commit_lock = Arc::clone(&self.commit_lock);
758        let _c = commit_lock.lock();
759        let epoch = self.epoch.bump_assigned();
760        {
761            let mut cat = self.catalog.write();
762            let before = cat.external_tables.len();
763            cat.external_tables.retain(|t| t.name != name);
764            if cat.external_tables.len() == before {
765                return Err(MongrelError::NotFound(format!(
766                    "external table {name:?} not found"
767                )));
768            }
769            cat.db_epoch = epoch.0;
770        }
771        let state_dir = self.root.join(VTAB_DIR).join(name);
772        if state_dir.exists() {
773            std::fs::remove_dir_all(state_dir)?;
774        }
775        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
776        self.advance_visible(epoch);
777        Ok(())
778    }
779
780    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
781        let txn_id = self.alloc_txn_id();
782        self.commit_transaction_with_external_states(
783            txn_id,
784            self.epoch.visible(),
785            Vec::new(),
786            vec![(name.to_string(), state.to_vec())],
787            None,
788        )
789    }
790
791    pub fn trigger_config(&self) -> TriggerConfig {
792        use std::sync::atomic::Ordering;
793        TriggerConfig {
794            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
795            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
796        }
797    }
798
799    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
800        use std::sync::atomic::Ordering;
801        if config.max_depth == 0 {
802            return Err(MongrelError::InvalidArgument(
803                "trigger max_depth must be greater than 0".into(),
804            ));
805        }
806        self.trigger_recursive
807            .store(config.recursive_triggers, Ordering::Relaxed);
808        self.trigger_max_depth
809            .store(config.max_depth, Ordering::Relaxed);
810        Ok(())
811    }
812
813    pub fn set_recursive_triggers(&self, recursive: bool) {
814        use std::sync::atomic::Ordering;
815        self.trigger_recursive.store(recursive, Ordering::Relaxed);
816    }
817
818    pub fn call_procedure(
819        &self,
820        name: &str,
821        args: HashMap<String, crate::Value>,
822    ) -> Result<ProcedureCallResult> {
823        let procedure = self
824            .procedure(name)
825            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
826        let args = bind_procedure_args(&procedure, args)?;
827        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
828        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
829        if has_writes {
830            let mut tx = self.begin();
831            let run = (|| {
832                for step in &procedure.body.steps {
833                    let output =
834                        self.execute_procedure_step(step, &args, &outputs, Some(&mut tx))?;
835                    outputs.insert(step.id().to_string(), output);
836                }
837                eval_return_output(&procedure.body.return_value, &args, &outputs)
838            })();
839            match run {
840                Ok(output) => {
841                    let epoch = tx.commit()?.0;
842                    Ok(ProcedureCallResult {
843                        epoch: Some(epoch),
844                        output,
845                    })
846                }
847                Err(e) => {
848                    tx.rollback();
849                    Err(e)
850                }
851            }
852        } else {
853            for step in &procedure.body.steps {
854                let output = self.execute_procedure_step(step, &args, &outputs, None)?;
855                outputs.insert(step.id().to_string(), output);
856            }
857            Ok(ProcedureCallResult {
858                epoch: None,
859                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
860            })
861        }
862    }
863
864    fn execute_procedure_step(
865        &self,
866        step: &ProcedureStep,
867        args: &HashMap<String, crate::Value>,
868        outputs: &HashMap<String, ProcedureCallOutput>,
869        tx: Option<&mut crate::txn::Transaction<'_>>,
870    ) -> Result<ProcedureCallOutput> {
871        match step {
872            ProcedureStep::NativeQuery {
873                table,
874                conditions,
875                projection,
876                limit,
877                ..
878            } => {
879                let mut q = crate::Query::new();
880                for condition in conditions {
881                    q = q.and(eval_condition(condition, args, outputs)?);
882                }
883                let handle = self.table(table)?;
884                let mut rows = handle.lock().query(&q)?;
885                if let Some(limit) = limit {
886                    rows.truncate(*limit);
887                }
888                let projection = projection.as_ref();
889                Ok(ProcedureCallOutput::Rows(
890                    rows.into_iter()
891                        .map(|row| ProcedureCallRow {
892                            row_id: Some(row.row_id),
893                            columns: match projection {
894                                Some(ids) => row
895                                    .columns
896                                    .into_iter()
897                                    .filter(|(id, _)| ids.contains(id))
898                                    .collect(),
899                                None => row.columns,
900                            },
901                        })
902                        .collect(),
903                ))
904            }
905            ProcedureStep::Put {
906                table,
907                cells,
908                returning,
909                ..
910            } => {
911                let tx = tx.ok_or_else(|| {
912                    MongrelError::InvalidArgument(
913                        "write procedure step requires a transaction".into(),
914                    )
915                })?;
916                let cells = eval_cells(cells, args, outputs)?;
917                if *returning {
918                    let out = tx.put_returning(table, cells)?;
919                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
920                        row_id: None,
921                        columns: out.row.columns.into_iter().collect(),
922                    }))
923                } else {
924                    tx.put(table, cells)?;
925                    Ok(ProcedureCallOutput::Null)
926                }
927            }
928            ProcedureStep::Upsert {
929                table,
930                cells,
931                update_cells,
932                returning,
933                ..
934            } => {
935                let tx = tx.ok_or_else(|| {
936                    MongrelError::InvalidArgument(
937                        "write procedure step requires a transaction".into(),
938                    )
939                })?;
940                let cells = eval_cells(cells, args, outputs)?;
941                let action = match update_cells {
942                    Some(update_cells) => {
943                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
944                    }
945                    None => crate::UpsertAction::DoNothing,
946                };
947                let out = tx.upsert(table, cells, action)?;
948                if *returning {
949                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
950                        row_id: None,
951                        columns: out.row.columns.into_iter().collect(),
952                    }))
953                } else {
954                    Ok(ProcedureCallOutput::Null)
955                }
956            }
957            ProcedureStep::DeleteByPk { table, pk, .. } => {
958                let tx = tx.ok_or_else(|| {
959                    MongrelError::InvalidArgument(
960                        "write procedure step requires a transaction".into(),
961                    )
962                })?;
963                let pk = eval_value(pk, args, outputs)?;
964                let handle = self.table(table)?;
965                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
966                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
967                })?;
968                tx.delete(table, row_id)?;
969                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
970            }
971            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
972                "DeleteRows procedure step is not supported by the core executor yet".into(),
973            )),
974            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
975                "SqlQuery procedure step must be executed by mongreldb-query".into(),
976            )),
977        }
978    }
979
980    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
981        let cat = self.catalog.read();
982        for step in &procedure.body.steps {
983            let Some(table_name) = step.table() else {
984                continue;
985            };
986            let schema = &cat
987                .live(table_name)
988                .ok_or_else(|| {
989                    MongrelError::InvalidArgument(format!(
990                        "procedure {:?} references unknown table {table_name:?}",
991                        procedure.name
992                    ))
993                })?
994                .schema;
995            match step {
996                ProcedureStep::NativeQuery {
997                    conditions,
998                    projection,
999                    ..
1000                } => {
1001                    for condition in conditions {
1002                        validate_condition_columns(condition, schema)?;
1003                    }
1004                    if let Some(projection) = projection {
1005                        for id in projection {
1006                            validate_column_id(*id, schema)?;
1007                        }
1008                    }
1009                }
1010                ProcedureStep::Put { cells, .. } => {
1011                    for cell in cells {
1012                        validate_column_id(cell.column_id, schema)?;
1013                    }
1014                }
1015                ProcedureStep::Upsert {
1016                    cells,
1017                    update_cells,
1018                    ..
1019                } => {
1020                    for cell in cells {
1021                        validate_column_id(cell.column_id, schema)?;
1022                    }
1023                    if let Some(update_cells) = update_cells {
1024                        for cell in update_cells {
1025                            validate_column_id(cell.column_id, schema)?;
1026                        }
1027                    }
1028                }
1029                ProcedureStep::DeleteByPk { .. } => {
1030                    if schema.primary_key().is_none() {
1031                        return Err(MongrelError::InvalidArgument(format!(
1032                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
1033                            procedure.name
1034                        )));
1035                    }
1036                }
1037                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
1038            }
1039        }
1040        Ok(())
1041    }
1042
1043    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
1044        let cat = self.catalog.read();
1045        let target_schema = match &trigger.target {
1046            TriggerTarget::Table(target_name) => cat
1047                .live(target_name)
1048                .ok_or_else(|| {
1049                    MongrelError::InvalidArgument(format!(
1050                        "trigger {:?} references unknown target table {target_name:?}",
1051                        trigger.name
1052                    ))
1053                })?
1054                .schema
1055                .clone(),
1056            TriggerTarget::View(_) => Schema {
1057                columns: trigger.target_columns.clone(),
1058                ..Schema::default()
1059            },
1060        };
1061        for col in &trigger.update_of {
1062            if target_schema.column(col).is_none() {
1063                return Err(MongrelError::InvalidArgument(format!(
1064                    "trigger {:?} UPDATE OF references unknown column {col:?}",
1065                    trigger.name
1066                )));
1067            }
1068        }
1069        if let Some(expr) = &trigger.when {
1070            validate_trigger_expr(expr, &target_schema, trigger.event)?;
1071        }
1072        for step in &trigger.program.steps {
1073            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
1074            {
1075                return Err(MongrelError::InvalidArgument(
1076                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
1077                ));
1078            }
1079            validate_trigger_step(step, &cat, &target_schema, trigger.event)?;
1080        }
1081        Ok(())
1082    }
1083
1084    /// Begin a new transaction reading at the current visible epoch.
1085    pub fn begin(&self) -> crate::txn::Transaction<'_> {
1086        let txn_id = self.alloc_txn_id();
1087        let read = Snapshot::at(self.epoch.visible());
1088        crate::txn::Transaction::new(self, txn_id, read)
1089    }
1090
1091    /// Begin a transaction whose trigger programs may route external-table DML
1092    /// through an application/query-layer module bridge.
1093    pub fn begin_with_external_trigger_bridge<'a>(
1094        &'a self,
1095        bridge: &'a dyn ExternalTriggerBridge,
1096    ) -> crate::txn::Transaction<'a> {
1097        let txn_id = self.alloc_txn_id();
1098        let read = Snapshot::at(self.epoch.visible());
1099        crate::txn::Transaction::new(self, txn_id, read).with_external_trigger_bridge(bridge)
1100    }
1101
1102    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
1103    pub fn transaction<T>(
1104        &self,
1105        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
1106    ) -> Result<T> {
1107        let mut tx = self.begin();
1108        match f(&mut tx) {
1109            Ok(out) => {
1110                tx.commit()?;
1111                Ok(out)
1112            }
1113            Err(e) => {
1114                tx.rollback();
1115                Err(e)
1116            }
1117        }
1118    }
1119
1120    /// Run `f` in a transaction with an external-trigger bridge; commit on
1121    /// `Ok`, rollback on `Err`.
1122    pub fn transaction_with_external_trigger_bridge<'a, T>(
1123        &'a self,
1124        bridge: &'a dyn ExternalTriggerBridge,
1125        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
1126    ) -> Result<T> {
1127        let mut tx = self.begin_with_external_trigger_bridge(bridge);
1128        match f(&mut tx) {
1129            Ok(out) => {
1130                tx.commit()?;
1131                Ok(out)
1132            }
1133            Err(e) => {
1134                tx.rollback();
1135                Err(e)
1136            }
1137        }
1138    }
1139
1140    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
1141    /// `Transaction::new` so registration happens **before** any read.
1142    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
1143        self.active_txns.register(epoch)
1144    }
1145
1146    fn fill_auto_increment_for_staging(
1147        &self,
1148        staging: &mut [(u64, crate::txn::Staged)],
1149    ) -> Result<()> {
1150        let tables = self.tables.read();
1151        for (table_id, staged) in staging {
1152            if let crate::txn::Staged::Put(cells) = staged {
1153                if let Some(handle) = tables.get(table_id) {
1154                    let mut t = handle.lock();
1155                    t.fill_auto_inc(cells)?;
1156                }
1157            }
1158        }
1159        Ok(())
1160    }
1161
1162    fn expand_table_triggers(
1163        &self,
1164        staging: &mut Vec<(u64, crate::txn::Staged)>,
1165        read_epoch: Epoch,
1166        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
1167        external_states: &mut Vec<(String, Vec<u8>)>,
1168    ) -> Result<()> {
1169        let mut external_writes = Vec::new();
1170        let config = self.trigger_config();
1171        if config.recursive_triggers {
1172            let chunk = std::mem::take(staging);
1173            let stacks = vec![Vec::new(); chunk.len()];
1174            *staging = self.expand_trigger_chunk(
1175                chunk,
1176                stacks,
1177                read_epoch,
1178                0,
1179                config.max_depth,
1180                &mut external_writes,
1181            )?;
1182            self.apply_external_trigger_writes(
1183                external_writes,
1184                external_trigger_bridge,
1185                external_states,
1186                staging,
1187            )?;
1188            return Ok(());
1189        }
1190
1191        let mut expansion = self.expand_table_triggers_once(staging, read_epoch, None)?;
1192        if !expansion.before.is_empty() {
1193            let mut final_staging = expansion.before;
1194            final_staging.extend(filter_ignored_staging(
1195                std::mem::take(staging),
1196                &expansion.ignored_indices,
1197            ));
1198            *staging = final_staging;
1199        } else if !expansion.ignored_indices.is_empty() {
1200            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
1201        }
1202        staging.append(&mut expansion.after);
1203        external_writes.append(&mut expansion.before_external);
1204        external_writes.append(&mut expansion.after_external);
1205        self.apply_external_trigger_writes(
1206            external_writes,
1207            external_trigger_bridge,
1208            external_states,
1209            staging,
1210        )?;
1211        Ok(())
1212    }
1213
1214    fn expand_trigger_chunk(
1215        &self,
1216        mut chunk: Vec<(u64, crate::txn::Staged)>,
1217        stacks: Vec<Vec<String>>,
1218        read_epoch: Epoch,
1219        depth: u32,
1220        max_depth: u32,
1221        external_writes: &mut Vec<ExternalTriggerWrite>,
1222    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
1223        if chunk.is_empty() {
1224            return Ok(Vec::new());
1225        }
1226        self.fill_auto_increment_for_staging(&mut chunk)?;
1227        let expansion = self.expand_table_triggers_once(&mut chunk, read_epoch, Some(&stacks))?;
1228        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
1229            let stack = expansion
1230                .before_stacks
1231                .first()
1232                .or_else(|| expansion.after_stacks.first())
1233                .cloned()
1234                .unwrap_or_default();
1235            return Err(MongrelError::Conflict(format!(
1236                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
1237                Self::format_trigger_stack(&stack)
1238            )));
1239        }
1240
1241        let mut out = Vec::new();
1242        external_writes.extend(expansion.before_external);
1243        out.extend(self.expand_trigger_chunk(
1244            expansion.before,
1245            expansion.before_stacks,
1246            read_epoch,
1247            depth + 1,
1248            max_depth,
1249            external_writes,
1250        )?);
1251        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
1252        external_writes.extend(expansion.after_external);
1253        out.extend(self.expand_trigger_chunk(
1254            expansion.after,
1255            expansion.after_stacks,
1256            read_epoch,
1257            depth + 1,
1258            max_depth,
1259            external_writes,
1260        )?);
1261        Ok(out)
1262    }
1263
1264    fn apply_external_trigger_writes(
1265        &self,
1266        writes: Vec<ExternalTriggerWrite>,
1267        bridge: Option<&dyn ExternalTriggerBridge>,
1268        external_states: &mut Vec<(String, Vec<u8>)>,
1269        staging: &mut Vec<(u64, crate::txn::Staged)>,
1270    ) -> Result<()> {
1271        if writes.is_empty() {
1272            return Ok(());
1273        }
1274        let bridge = bridge.ok_or_else(|| {
1275            MongrelError::InvalidArgument(
1276                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
1277            )
1278        })?;
1279        for write in writes {
1280            let table = write.table().to_string();
1281            let entry = self.external_table(&table).ok_or_else(|| {
1282                MongrelError::NotFound(format!("external table {table:?} not found"))
1283            })?;
1284            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
1285            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
1286            external_states.push((table, result.state));
1287            for base_write in result.base_writes {
1288                match base_write {
1289                    ExternalTriggerBaseWrite::Put { table, cells } => {
1290                        let table_id = self.table_id(&table)?;
1291                        staging.push((table_id, crate::txn::Staged::Put(cells)));
1292                    }
1293                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
1294                        let table_id = self.table_id(&table)?;
1295                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
1296                    }
1297                }
1298            }
1299        }
1300        dedup_external_states_in_place(external_states);
1301        Ok(())
1302    }
1303
1304    fn expand_table_triggers_once(
1305        &self,
1306        staging: &mut Vec<(u64, crate::txn::Staged)>,
1307        read_epoch: Epoch,
1308        trigger_stacks: Option<&[Vec<String>]>,
1309    ) -> Result<TriggerExpansion> {
1310        let triggers: Vec<StoredTrigger> = self
1311            .catalog
1312            .read()
1313            .triggers
1314            .iter()
1315            .filter(|entry| {
1316                entry.trigger.enabled
1317                    && matches!(
1318                        entry.trigger.timing,
1319                        TriggerTiming::Before | TriggerTiming::After
1320                    )
1321                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
1322            })
1323            .map(|entry| entry.trigger.clone())
1324            .collect();
1325        if triggers.is_empty() || staging.is_empty() {
1326            return Ok(TriggerExpansion::default());
1327        }
1328
1329        let before_triggers = triggers
1330            .iter()
1331            .filter(|trigger| trigger.timing == TriggerTiming::Before)
1332            .cloned()
1333            .collect::<Vec<_>>();
1334        let after_triggers = triggers
1335            .iter()
1336            .filter(|trigger| trigger.timing == TriggerTiming::After)
1337            .cloned()
1338            .collect::<Vec<_>>();
1339
1340        let mut before_added = Vec::new();
1341        let mut before_stacks = Vec::new();
1342        let mut before_external = Vec::new();
1343        let mut ignored_indices = std::collections::BTreeSet::new();
1344        if !before_triggers.is_empty() {
1345            let before_events =
1346                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?;
1347            let mut out = TriggerProgramOutput {
1348                added: &mut before_added,
1349                added_stacks: &mut before_stacks,
1350                added_external: &mut before_external,
1351                ignored_indices: &mut ignored_indices,
1352            };
1353            self.execute_triggers_for_events(
1354                &before_triggers,
1355                &before_events,
1356                Some(staging),
1357                &mut out,
1358            )?;
1359        }
1360
1361        let after_events = if after_triggers.is_empty() {
1362            Vec::new()
1363        } else {
1364            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?
1365                .into_iter()
1366                .filter(|event| {
1367                    !event
1368                        .op_indices
1369                        .iter()
1370                        .any(|idx| ignored_indices.contains(idx))
1371                })
1372                .collect()
1373        };
1374
1375        let mut after_added = Vec::new();
1376        let mut after_stacks = Vec::new();
1377        let mut after_external = Vec::new();
1378        let mut out = TriggerProgramOutput {
1379            added: &mut after_added,
1380            added_stacks: &mut after_stacks,
1381            added_external: &mut after_external,
1382            ignored_indices: &mut ignored_indices,
1383        };
1384        self.execute_triggers_for_events(&after_triggers, &after_events, None, &mut out)?;
1385        Ok(TriggerExpansion {
1386            before: before_added,
1387            before_stacks,
1388            before_external,
1389            after: after_added,
1390            after_stacks,
1391            after_external,
1392            ignored_indices,
1393        })
1394    }
1395
1396    fn execute_triggers_for_events(
1397        &self,
1398        triggers: &[StoredTrigger],
1399        events: &[WriteEvent],
1400        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
1401        out: &mut TriggerProgramOutput<'_>,
1402    ) -> Result<()> {
1403        for event in events {
1404            for trigger in triggers {
1405                if event
1406                    .op_indices
1407                    .iter()
1408                    .any(|idx| out.ignored_indices.contains(idx))
1409                {
1410                    break;
1411                }
1412                let matches = {
1413                    let cat = self.catalog.read();
1414                    trigger_matches_event(trigger, event, &cat)?
1415                };
1416                if !matches {
1417                    continue;
1418                }
1419                if let Some(when) = &trigger.when {
1420                    if !eval_trigger_expr(when, event)? {
1421                        continue;
1422                    }
1423                }
1424                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
1425                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
1426                    return Err(MongrelError::Conflict(format!(
1427                        "trigger recursion cycle detected; trigger stack: {}",
1428                        Self::format_trigger_stack(&trigger_stack)
1429                    )));
1430                }
1431                let outcome = match staging.as_mut() {
1432                    Some(staging) => self.execute_trigger_program(
1433                        trigger,
1434                        event,
1435                        Some(&mut **staging),
1436                        out,
1437                        &trigger_stack,
1438                    )?,
1439                    None => {
1440                        self.execute_trigger_program(trigger, event, None, out, &trigger_stack)?
1441                    }
1442                };
1443                if outcome == TriggerProgramOutcome::Ignore {
1444                    out.ignored_indices.extend(event.op_indices.iter().copied());
1445                    break;
1446                }
1447            }
1448        }
1449        Ok(())
1450    }
1451
1452    fn trigger_events_for_staging(
1453        &self,
1454        staging: &[(u64, crate::txn::Staged)],
1455        read_epoch: Epoch,
1456        trigger_stacks: Option<&[Vec<String>]>,
1457    ) -> Result<Vec<WriteEvent>> {
1458        use crate::txn::Staged;
1459        use std::collections::{HashMap, VecDeque};
1460
1461        let snapshot = Snapshot::at(read_epoch);
1462        let cat = self.catalog.read();
1463        let mut table_names = HashMap::new();
1464        let mut table_schemas = HashMap::new();
1465        for entry in cat
1466            .tables
1467            .iter()
1468            .filter(|entry| matches!(entry.state, TableState::Live))
1469        {
1470            table_names.insert(entry.table_id, entry.name.clone());
1471            table_schemas.insert(entry.table_id, entry.schema.clone());
1472        }
1473        drop(cat);
1474
1475        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
1476        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
1477        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
1478
1479        for (idx, (table_id, staged)) in staging.iter().enumerate() {
1480            let Some(schema) = table_schemas.get(table_id) else {
1481                continue;
1482            };
1483            let Some(pk) = schema.primary_key() else {
1484                continue;
1485            };
1486            match staged {
1487                Staged::Delete(row_id) => {
1488                    let handle = self.table_by_id(*table_id)?;
1489                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
1490                        continue;
1491                    };
1492                    let Some(pk_value) = row.columns.get(&pk.id) else {
1493                        continue;
1494                    };
1495                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
1496                    delete_by_key
1497                        .entry((*table_id, pk_value.encode_key()))
1498                        .or_default()
1499                        .push_back(idx);
1500                }
1501                Staged::Put(cells) => {
1502                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
1503                        put_by_key
1504                            .entry((*table_id, value.encode_key()))
1505                            .or_default()
1506                            .push_back(idx);
1507                    }
1508                }
1509                Staged::Truncate => {}
1510            }
1511        }
1512
1513        let mut paired_delete = std::collections::HashSet::new();
1514        let mut paired_put = std::collections::HashSet::new();
1515        let mut events = Vec::new();
1516
1517        for (key, deletes) in delete_by_key.iter_mut() {
1518            let Some(puts) = put_by_key.get_mut(key) else {
1519                continue;
1520            };
1521            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
1522                paired_delete.insert(delete_idx);
1523                paired_put.insert(put_idx);
1524                let (table_id, _) = &staging[put_idx];
1525                let Some(table_name) = table_names.get(table_id).cloned() else {
1526                    continue;
1527                };
1528                let old = old_rows.get(&delete_idx).cloned();
1529                let new = match &staging[put_idx].1 {
1530                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
1531                    _ => None,
1532                };
1533                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
1534                events.push(WriteEvent {
1535                    table: table_name,
1536                    kind: TriggerEvent::Update,
1537                    old,
1538                    new,
1539                    changed_columns,
1540                    op_indices: vec![delete_idx, put_idx],
1541                    put_idx: Some(put_idx),
1542                    trigger_stack: Self::trigger_stack_for_indices(
1543                        trigger_stacks,
1544                        &[delete_idx, put_idx],
1545                    ),
1546                });
1547            }
1548        }
1549
1550        for (idx, (table_id, staged)) in staging.iter().enumerate() {
1551            let Some(table_name) = table_names.get(table_id).cloned() else {
1552                continue;
1553            };
1554            match staged {
1555                Staged::Put(cells) if !paired_put.contains(&idx) => {
1556                    let new = Some(TriggerRowImage::from_cells(cells));
1557                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
1558                    events.push(WriteEvent {
1559                        table: table_name,
1560                        kind: TriggerEvent::Insert,
1561                        old: None,
1562                        new,
1563                        changed_columns,
1564                        op_indices: vec![idx],
1565                        put_idx: Some(idx),
1566                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
1567                    });
1568                }
1569                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
1570                    let old = match old_rows.get(&idx).cloned() {
1571                        Some(old) => Some(old),
1572                        None => {
1573                            let handle = self.table_by_id(*table_id)?;
1574                            let row = handle.lock().get(*row_id, snapshot);
1575                            row.map(TriggerRowImage::from_row)
1576                        }
1577                    };
1578                    let Some(old) = old else {
1579                        continue;
1580                    };
1581                    let changed_columns = old.columns.keys().copied().collect();
1582                    events.push(WriteEvent {
1583                        table: table_name,
1584                        kind: TriggerEvent::Delete,
1585                        old: Some(old),
1586                        new: None,
1587                        changed_columns,
1588                        op_indices: vec![idx],
1589                        put_idx: None,
1590                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
1591                    });
1592                }
1593                Staged::Truncate => {}
1594                _ => {}
1595            }
1596        }
1597
1598        Ok(events)
1599    }
1600
1601    fn execute_trigger_program(
1602        &self,
1603        trigger: &StoredTrigger,
1604        event: &WriteEvent,
1605        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
1606        out: &mut TriggerProgramOutput<'_>,
1607        trigger_stack: &[String],
1608    ) -> Result<TriggerProgramOutcome> {
1609        let mut event = event.clone();
1610        for step in &trigger.program.steps {
1611            match step {
1612                TriggerStep::SetNew { cells } => {
1613                    if trigger.timing != TriggerTiming::Before {
1614                        return Err(MongrelError::InvalidArgument(
1615                            "SetNew trigger step is only valid in BEFORE triggers".into(),
1616                        ));
1617                    }
1618                    let put_idx = event.put_idx.ok_or_else(|| {
1619                        MongrelError::InvalidArgument(
1620                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
1621                        )
1622                    })?;
1623                    let staging = staging.as_deref_mut().ok_or_else(|| {
1624                        MongrelError::InvalidArgument(
1625                            "SetNew trigger step requires mutable trigger staging".into(),
1626                        )
1627                    })?;
1628                    let Some((_, crate::txn::Staged::Put(row_cells))) = staging.get_mut(put_idx)
1629                    else {
1630                        return Err(MongrelError::InvalidArgument(
1631                            "SetNew trigger step target row is not mutable".into(),
1632                        ));
1633                    };
1634                    for (column_id, value) in eval_trigger_cells(cells, &event)? {
1635                        row_cells.retain(|(id, _)| *id != column_id);
1636                        row_cells.push((column_id, value.clone()));
1637                        if let Some(new) = &mut event.new {
1638                            new.columns.insert(column_id, value);
1639                        }
1640                    }
1641                    row_cells.sort_by_key(|(id, _)| *id);
1642                }
1643                TriggerStep::Insert { table, cells } => {
1644                    let cells = eval_trigger_cells(cells, &event)?;
1645                    if let Ok(table_id) = self.table_id(table) {
1646                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
1647                        out.added_stacks.push(trigger_stack.to_vec());
1648                    } else if self.external_table(table).is_some() {
1649                        out.added_external.push(ExternalTriggerWrite::Insert {
1650                            table: table.clone(),
1651                            cells,
1652                        });
1653                    } else {
1654                        return Err(MongrelError::NotFound(format!(
1655                            "trigger {:?} insert target {table:?} not found",
1656                            trigger.name
1657                        )));
1658                    }
1659                }
1660                TriggerStep::UpdateByPk { table, pk, cells } => {
1661                    let pk = eval_trigger_value(pk, &event)?;
1662                    let cells = eval_trigger_cells(cells, &event)?;
1663                    if self.external_table(table).is_some() {
1664                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
1665                            table: table.clone(),
1666                            pk,
1667                            cells,
1668                        });
1669                    } else {
1670                        let row_id = self
1671                            .table(table)?
1672                            .lock()
1673                            .lookup_pk(&pk.encode_key())
1674                            .ok_or_else(|| {
1675                                MongrelError::NotFound(format!(
1676                                    "trigger {:?} update target not found",
1677                                    trigger.name
1678                                ))
1679                            })?;
1680                        let handle = self.table(table)?;
1681                        let snapshot = Snapshot::at(self.epoch.visible());
1682                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
1683                            MongrelError::NotFound(format!(
1684                                "trigger {:?} update target not visible",
1685                                trigger.name
1686                            ))
1687                        })?;
1688                        let mut merged = old.columns;
1689                        for (column_id, value) in cells {
1690                            merged.insert(column_id, value);
1691                        }
1692                        out.added
1693                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
1694                        out.added_stacks.push(trigger_stack.to_vec());
1695                        out.added.push((
1696                            self.table_id(table)?,
1697                            crate::txn::Staged::Put(merged.into_iter().collect()),
1698                        ));
1699                        out.added_stacks.push(trigger_stack.to_vec());
1700                    }
1701                }
1702                TriggerStep::DeleteByPk { table, pk } => {
1703                    let pk = eval_trigger_value(pk, &event)?;
1704                    if self.external_table(table).is_some() {
1705                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
1706                            table: table.clone(),
1707                            pk,
1708                        });
1709                    } else {
1710                        let row_id = self
1711                            .table(table)?
1712                            .lock()
1713                            .lookup_pk(&pk.encode_key())
1714                            .ok_or_else(|| {
1715                                MongrelError::NotFound(format!(
1716                                    "trigger {:?} delete target not found",
1717                                    trigger.name
1718                                ))
1719                            })?;
1720                        out.added
1721                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
1722                        out.added_stacks.push(trigger_stack.to_vec());
1723                    }
1724                }
1725                TriggerStep::Select { .. } => {}
1726                TriggerStep::Raise { action, message } => match action {
1727                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
1728                    TriggerRaiseAction::Abort
1729                    | TriggerRaiseAction::Fail
1730                    | TriggerRaiseAction::Rollback => {
1731                        let message = eval_trigger_value(message, &event)?;
1732                        return Err(MongrelError::Conflict(format!(
1733                            "trigger {:?} raised: {}; trigger stack: {}",
1734                            trigger.name,
1735                            trigger_message(message),
1736                            Self::format_trigger_stack(trigger_stack)
1737                        )));
1738                    }
1739                },
1740            }
1741        }
1742        Ok(TriggerProgramOutcome::Continue)
1743    }
1744
1745    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
1746        let Some(stacks) = stacks else {
1747            return Vec::new();
1748        };
1749        let mut out = Vec::new();
1750        for idx in indices {
1751            let Some(stack) = stacks.get(*idx) else {
1752                continue;
1753            };
1754            for name in stack {
1755                if !out.iter().any(|existing| existing == name) {
1756                    out.push(name.clone());
1757                }
1758            }
1759        }
1760        out
1761    }
1762
1763    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
1764        let mut out = stack.to_vec();
1765        out.push(trigger_name.to_string());
1766        out
1767    }
1768
1769    fn format_trigger_stack(stack: &[String]) -> String {
1770        if stack.is_empty() {
1771            "<root>".into()
1772        } else {
1773            stack.join(" -> ")
1774        }
1775    }
1776
1777    /// Authoritatively validate every declared constraint on the staged write
1778    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
1779    /// SET NULL actions into explicit child ops. Called from
1780    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
1781    /// violation as an `Err`, aborting the commit atomically. This is the
1782    /// server-side authority point: concurrent remote writers that each pass
1783    /// their own client-side checks still cannot both commit a violating batch.
1784    ///
1785    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
1786    /// intra-transaction dedup; concurrent-txn races are additionally caught by
1787    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
1788    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
1789    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
1790    /// RESTRICT-only (cascade-truncate is unsupported).
1791    fn validate_constraints(
1792        &self,
1793        staging: &mut Vec<(u64, crate::txn::Staged)>,
1794        read_epoch: Epoch,
1795    ) -> Result<()> {
1796        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
1797        use crate::memtable::Row;
1798        use crate::txn::Staged;
1799        use std::collections::HashSet;
1800
1801        let snapshot = Snapshot::at(read_epoch);
1802        let cat = self.catalog.read();
1803
1804        // Collect live (id, name, constraints-bearing?) for staged tables.
1805        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
1806            .tables
1807            .iter()
1808            .filter(|e| matches!(e.state, TableState::Live))
1809            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
1810            .collect();
1811
1812        // Fast path: bail if no live table declares any constraints at all.
1813        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
1814        if !any_constraints {
1815            return Ok(());
1816        }
1817
1818        // Lazily-loaded visible rows per table, shared across checks.
1819        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
1820        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
1821            if let Some(r) = rows_cache.get(&table_id) {
1822                return Ok(r.clone());
1823            }
1824            let handle = self.table_by_id(table_id)?;
1825            let rows = handle.lock().visible_rows(snapshot)?;
1826            rows_cache.insert(table_id, rows.clone());
1827            Ok(rows)
1828        };
1829
1830        // ── Phase A: expand ON DELETE CASCADE / SET NULL into explicit child
1831        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
1832        // enforced as a violation in Phase B. `cascaded` records every delete
1833        // we have already expanded so a self-referential CASCADE FK cannot loop.
1834        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
1835        loop {
1836            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
1837            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
1838                .iter()
1839                .filter_map(|(t, op)| match op {
1840                    Staged::Delete(rid) => Some((*t, *rid)),
1841                    _ => None,
1842                })
1843                .collect();
1844            for (table_id, rid) in deletes {
1845                if !cascaded.insert((table_id, rid.0)) {
1846                    continue;
1847                }
1848                let Some(tname) = live
1849                    .iter()
1850                    .find(|(t, _, _)| *t == table_id)
1851                    .map(|(_, n, _)| *n)
1852                else {
1853                    continue;
1854                };
1855                let parent_handle = self.table_by_id(table_id)?;
1856                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
1857                    continue;
1858                };
1859                for (child_id, _child_name, child_schema) in &live {
1860                    for fk in &child_schema.constraints.foreign_keys {
1861                        if fk.ref_table != tname {
1862                            continue;
1863                        }
1864                        let Some(parent_key) =
1865                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
1866                        else {
1867                            continue;
1868                        };
1869                        match fk.on_delete {
1870                            FkAction::Restrict => continue,
1871                            FkAction::Cascade => {
1872                                let child_rows = load_rows(*child_id)?;
1873                                for cr in &child_rows {
1874                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
1875                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
1876                                            == Some(parent_key.as_slice())
1877                                    {
1878                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
1879                                    }
1880                                }
1881                            }
1882                            FkAction::SetNull => {
1883                                let child_rows = load_rows(*child_id)?;
1884                                for cr in &child_rows {
1885                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
1886                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
1887                                            == Some(parent_key.as_slice())
1888                                    {
1889                                        // Re-emit the child row with the FK
1890                                        // columns set to NULL (delete + put).
1891                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
1892                                            .columns
1893                                            .iter()
1894                                            .map(|(k, v)| (*k, v.clone()))
1895                                            .collect();
1896                                        for cid in &fk.columns {
1897                                            cells.retain(|(k, _)| k != cid);
1898                                            cells.push((*cid, crate::memtable::Value::Null));
1899                                        }
1900                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
1901                                        new_ops.push((*child_id, Staged::Put(cells)));
1902                                    }
1903                                }
1904                            }
1905                        }
1906                    }
1907                }
1908            }
1909            if new_ops.is_empty() {
1910                break;
1911            }
1912            staging.extend(new_ops);
1913        }
1914
1915        // Rows staged for deletion in THIS transaction (now including cascaded
1916        // deletes). Used to exclude the old version of an updated row from
1917        // unique-existence scans.
1918        let staged_deletes: HashSet<(u64, u64)> = staging
1919            .iter()
1920            .filter_map(|(t, op)| match op {
1921                Staged::Delete(rid) => Some((*t, rid.0)),
1922                _ => None,
1923            })
1924            .collect();
1925
1926        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
1927        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
1928
1929        // ── Phase B: validate the fully-expanded staging set.
1930        for (table_id, op) in staging.iter() {
1931            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
1932            else {
1933                continue;
1934            };
1935            let cells_map: HashMap<u16, crate::memtable::Value>;
1936            match op {
1937                Staged::Put(cells) => {
1938                    cells_map = cells.iter().cloned().collect();
1939
1940                    // CHECK constraints.
1941                    if !schema.constraints.checks.is_empty() {
1942                        validate_checks(&schema.constraints.checks, &cells_map)?;
1943                    }
1944
1945                    // UNIQUE (non-PK) constraints.
1946                    for uc in &schema.constraints.uniques {
1947                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
1948                            continue; // NULL in a constrained column → skip (SQL).
1949                        };
1950                        let marker = (*table_id, uc.id, key.clone());
1951                        if !seen_unique.insert(marker) {
1952                            return Err(MongrelError::Conflict(format!(
1953                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
1954                                uc.name
1955                            )));
1956                        }
1957                        let rows = load_rows(*table_id)?;
1958                        for r in &rows {
1959                            // Skip rows this same transaction is deleting (the
1960                            // old version of an updated/cascade-deleted row).
1961                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
1962                                continue;
1963                            }
1964                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
1965                                if theirs == key {
1966                                    return Err(MongrelError::Conflict(format!(
1967                                        "UNIQUE constraint '{}' on table '{tname}' violated",
1968                                        uc.name
1969                                    )));
1970                                }
1971                            }
1972                        }
1973                    }
1974
1975                    // FK insert-side: parent must exist.
1976                    for fk in &schema.constraints.foreign_keys {
1977                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
1978                            continue; // NULL FK component → not checked (SQL).
1979                        };
1980                        let Some(parent_id) = cat
1981                            .tables
1982                            .iter()
1983                            .find(|t| t.name == fk.ref_table)
1984                            .map(|t| t.table_id)
1985                        else {
1986                            return Err(MongrelError::InvalidArgument(format!(
1987                                "FOREIGN KEY '{}' references unknown table '{}'",
1988                                fk.name, fk.ref_table
1989                            )));
1990                        };
1991                        let parent_rows = load_rows(parent_id)?;
1992                        let mut found = false;
1993                        for r in &parent_rows {
1994                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
1995                                continue;
1996                            }
1997                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
1998                                if pkey == child_key {
1999                                    found = true;
2000                                    break;
2001                                }
2002                            }
2003                        }
2004                        if !found {
2005                            return Err(MongrelError::Conflict(format!(
2006                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
2007                                fk.name, fk.ref_table
2008                            )));
2009                        }
2010                    }
2011                }
2012                Staged::Delete(rid) => {
2013                    // FK ON DELETE RESTRICT: a child row (whose FK action is
2014                    // RESTRICT) referencing this parent blocks the delete.
2015                    // CASCADE/SET NULL children were expanded in Phase A.
2016                    let parent_handle = self.table_by_id(*table_id)?;
2017                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
2018                        continue;
2019                    };
2020                    for (child_id, child_name, child_schema) in &live {
2021                        for fk in &child_schema.constraints.foreign_keys {
2022                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
2023                                continue;
2024                            }
2025                            let Some(parent_key) =
2026                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
2027                            else {
2028                                continue;
2029                            };
2030                            let child_rows = load_rows(*child_id)?;
2031                            for r in &child_rows {
2032                                // A child already being deleted by this txn
2033                                // (cascade/inline) is not a restrict violation.
2034                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
2035                                    continue;
2036                                }
2037                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
2038                                    if ck == parent_key {
2039                                        return Err(MongrelError::Conflict(format!(
2040                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
2041                                            fk.name
2042                                        )));
2043                                    }
2044                                }
2045                            }
2046                        }
2047                    }
2048                }
2049                Staged::Truncate => {
2050                    // Truncate is RESTRICT-only: reject if any child references
2051                    // this table (any FK action), since cascade-truncate is
2052                    // unsupported.
2053                    for (child_id, child_name, child_schema) in &live {
2054                        for fk in &child_schema.constraints.foreign_keys {
2055                            if fk.ref_table != tname {
2056                                continue;
2057                            }
2058                            let child_rows = load_rows(*child_id)?;
2059                            if child_rows
2060                                .iter()
2061                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
2062                            {
2063                                return Err(MongrelError::Conflict(format!(
2064                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
2065                                    fk.name
2066                                )));
2067                            }
2068                        }
2069                    }
2070                }
2071            }
2072        }
2073        Ok(())
2074    }
2075
2076    /// Seal a transaction (spec §9.3):
2077    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
2078    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
2079    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
2080    ///    group-sync, record conflict keys.
2081    /// 3. Publish — apply to tables, advance visible in-order.
2082    pub(crate) fn commit_transaction_with_external_states(
2083        &self,
2084        txn_id: u64,
2085        read_epoch: Epoch,
2086        mut staging: Vec<(u64, crate::txn::Staged)>,
2087        external_states: Vec<(String, Vec<u8>)>,
2088        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
2089    ) -> Result<Epoch> {
2090        use crate::memtable::Row;
2091        use crate::txn::{Staged, StagedOp, WriteKey};
2092        use crate::wal::Op;
2093        use std::collections::hash_map::DefaultHasher;
2094        use std::hash::{Hash, Hasher};
2095        use std::sync::atomic::Ordering;
2096
2097        if self.poisoned.load(Ordering::Relaxed) {
2098            return Err(MongrelError::Other(
2099                "database poisoned by fsync error".into(),
2100            ));
2101        }
2102        let mut external_states = dedup_external_states(external_states);
2103        if !external_states.is_empty() {
2104            let cat = self.catalog.read();
2105            for (name, _) in &external_states {
2106                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
2107                    return Err(MongrelError::NotFound(format!(
2108                        "external table {name:?} not found"
2109                    )));
2110                }
2111            }
2112        }
2113
2114        // ── 1. Prepare: fill generated values, expand triggers, validate, then
2115        // derive write keys from the final atomic write set.
2116        self.fill_auto_increment_for_staging(&mut staging)?;
2117        self.expand_table_triggers(
2118            &mut staging,
2119            read_epoch,
2120            external_trigger_bridge,
2121            &mut external_states,
2122        )?;
2123        self.fill_auto_increment_for_staging(&mut staging)?;
2124        external_states = dedup_external_states(external_states);
2125
2126        // Validate declarative constraints (unique / FK / check) under the read
2127        // snapshot, outside the WAL mutex. Trigger-produced writes are included
2128        // here, so the batch either satisfies every declared constraint or is
2129        // rejected atomically.
2130        self.validate_constraints(&mut staging, read_epoch)?;
2131
2132        let write_keys = {
2133            let cat = self.catalog.read();
2134            let mut keys: Vec<WriteKey> = Vec::new();
2135            for (table_id, staged) in &staging {
2136                match staged {
2137                    Staged::Put(cells) => {
2138                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
2139                            for col in &entry.schema.columns {
2140                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
2141                                    if let Some((_, val)) =
2142                                        cells.iter().find(|(id, _)| *id == col.id)
2143                                    {
2144                                        let mut h = DefaultHasher::new();
2145                                        val.encode_key().hash(&mut h);
2146                                        keys.push(WriteKey::Unique {
2147                                            table_id: *table_id,
2148                                            index_id: 0,
2149                                            key_hash: h.finish(),
2150                                        });
2151                                    }
2152                                }
2153                            }
2154                            // Declared non-PK unique constraints register a
2155                            // `WriteKey::Unique` (namespace-separated from the
2156                            // PK's index_id==0 by setting the high bit) so two
2157                            // concurrent transactions inserting the same key
2158                            // cannot both commit. Rows with any NULL constrained
2159                            // column are skipped (SQL semantics).
2160                            for uc in &entry.schema.constraints.uniques {
2161                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
2162                                    &uc.columns,
2163                                    &cells.iter().cloned().collect(),
2164                                ) {
2165                                    let mut h = DefaultHasher::new();
2166                                    key_bytes.hash(&mut h);
2167                                    keys.push(WriteKey::Unique {
2168                                        table_id: *table_id,
2169                                        index_id: uc.id | 0x8000,
2170                                        key_hash: h.finish(),
2171                                    });
2172                                }
2173                            }
2174                        }
2175                    }
2176                    Staged::Delete(rid) => keys.push(WriteKey::Row {
2177                        table_id: *table_id,
2178                        row_id: rid.0,
2179                    }),
2180                    Staged::Truncate => keys.push(WriteKey::Table {
2181                        table_id: *table_id,
2182                    }),
2183                }
2184            }
2185            for (name, _) in &external_states {
2186                let mut h = DefaultHasher::new();
2187                name.hash(&mut h);
2188                keys.push(WriteKey::Unique {
2189                    table_id: EXTERNAL_TABLE_ID,
2190                    index_id: 0,
2191                    key_hash: h.finish(),
2192                });
2193            }
2194            keys
2195        };
2196
2197        // Opportunistic pruning.
2198        let min_active = self.active_txns.min_read_epoch();
2199        if min_active < u64::MAX {
2200            self.conflicts.prune_below(Epoch(min_active));
2201        }
2202
2203        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
2204        // §8.5, review fix #17). Snapshot the conflict-index version so the
2205        // sequencer only re-checks if new commits arrived in the interim.
2206        if self.conflicts.conflicts(&write_keys, read_epoch) {
2207            return Err(MongrelError::Conflict(
2208                "write-write conflict (pre-validate, first-committer-wins)".into(),
2209            ));
2210        }
2211        let pre_validate_version = self.conflicts.version();
2212
2213        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
2214        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
2215        // streamed as Put records; they are linked at publish time.
2216        let mut spilled: Vec<SpilledRun> = Vec::new();
2217        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
2218        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
2219        // as the spill runs are live (registered on first spill, dropped at the
2220        // end of this function on commit/abort/error).
2221        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
2222        {
2223            let mut table_bytes: HashMap<u64, usize> = HashMap::new();
2224            for (table_id, staged) in &staging {
2225                if let Staged::Put(cells) = staged {
2226                    *table_bytes.entry(*table_id).or_default() += cells.len() * 16;
2227                }
2228            }
2229            let tables = self.tables.read();
2230            for (&table_id, &bytes) in &table_bytes {
2231                if bytes as u64
2232                    <= self
2233                        .spill_threshold
2234                        .load(std::sync::atomic::Ordering::Relaxed)
2235                {
2236                    continue;
2237                }
2238                let Some(handle) = tables.get(&table_id) else {
2239                    continue;
2240                };
2241                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
2242                let mut t = handle.lock();
2243                let tdir = t.table_dir().to_path_buf();
2244                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
2245                std::fs::create_dir_all(&txn_dir)?;
2246                let run_id = t.alloc_run_id() as u128;
2247                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
2248
2249                let mut rows: Vec<Row> = Vec::new();
2250                for (tid, staged) in &staging {
2251                    if *tid != table_id {
2252                        continue;
2253                    }
2254                    if let Staged::Put(cells) = staged {
2255                        t.validate_cells_not_null(cells)?;
2256                        let row_id = t.alloc_row_id();
2257                        let mut row = Row::new(row_id, Epoch(0));
2258                        for (c, v) in cells {
2259                            row.columns.insert(*c, v.clone());
2260                        }
2261                        rows.push(row);
2262                    }
2263                }
2264                let schema = t.schema_ref().clone();
2265                let kek = t.kek_ref().cloned();
2266                let specs = t.indexable_column_specs();
2267                drop(t);
2268
2269                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
2270                    .uniform_epoch(true);
2271                if let Some(ref kek) = kek {
2272                    writer = writer.with_encryption(kek.as_ref(), specs);
2273                }
2274                let header = writer.write(&pending_path, &rows)?;
2275                let row_count = header.row_count;
2276                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
2277                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
2278
2279                spilled.push(SpilledRun {
2280                    table_id,
2281                    run_id,
2282                    pending_path,
2283                    rows,
2284                    row_count,
2285                    min_rid,
2286                    max_rid,
2287                });
2288                spilled_tables.insert(table_id);
2289            }
2290        }
2291
2292        // Test seam: let a test race `gc()` against this in-flight spill.
2293        if spill_guard.is_some() {
2294            if let Some(hook) = self.spill_hook.lock().as_ref() {
2295                hook();
2296            }
2297        }
2298
2299        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
2300        // Allocating row ids + building the rows here (lock order: table handle →
2301        // nothing) means the sequencer never locks a table handle while holding
2302        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
2303        // the table handle THEN the shared WAL; if the sequencer did the reverse
2304        // (WAL then handle) the two paths would deadlock (review fix: B1).
2305        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
2306        // Row ids are allocated here, before the sequencer's delta conflict
2307        // re-check, so a losing txn leaks the ids it reserved — harmless, the
2308        // u64 row-id space is monotonic and gaps are expected (spills do the same).
2309        let mut prebuilt: Vec<Option<Row>> = Vec::with_capacity(staging.len());
2310        {
2311            let tables = self.tables.read();
2312            for (table_id, staged) in &staging {
2313                match staged {
2314                    Staged::Put(cells) if !spilled_tables.contains(table_id) => {
2315                        let handle = tables.get(table_id).ok_or_else(|| {
2316                            MongrelError::NotFound(format!("table {table_id} not mounted"))
2317                        })?;
2318                        let mut t = handle.lock();
2319                        t.validate_cells_not_null(cells)?;
2320                        let row_id = t.alloc_row_id();
2321                        drop(t);
2322                        let mut row = Row::new(row_id, Epoch(0));
2323                        for (c, v) in cells {
2324                            row.columns.insert(*c, v.clone());
2325                        }
2326                        prebuilt.push(Some(row));
2327                    }
2328                    Staged::Put(_) | Staged::Delete(_) | Staged::Truncate => prebuilt.push(None),
2329                }
2330            }
2331        }
2332
2333        let mut prepared_external = Vec::with_capacity(external_states.len());
2334        for (name, state) in &external_states {
2335            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
2336            prepared_external.push((name.clone(), state.clone(), pending));
2337        }
2338
2339        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
2340        let added_runs: Vec<crate::wal::AddedRun> = spilled
2341            .iter()
2342            .map(|s| crate::wal::AddedRun {
2343                table_id: s.table_id,
2344                run_id: s.run_id,
2345                row_count: s.row_count,
2346                level: 0,
2347                min_row_id: s.min_rid,
2348                max_row_id: s.max_rid,
2349                content_hash: [0u8; 32],
2350            })
2351            .collect();
2352        let (new_epoch, applies, commit_seq) = {
2353            let mut wal = self.shared_wal.lock();
2354
2355            // Re-check only if the conflict index advanced since pre-validation
2356            // (bounded delta — spec §8.5, review fix #17). If the version is
2357            // unchanged, the pre-check result is still valid and the sequencer
2358            // does O(1) work regardless of write-set size.
2359            if self.conflicts.version() != pre_validate_version
2360                && self.conflicts.conflicts(&write_keys, read_epoch)
2361            {
2362                // Abort: this txn assigned no epoch yet, so drop the quarantined
2363                // spill runs we wrote during prepare instead of leaking them in
2364                // `_txn/` until the next GC/reopen sweep.
2365                drop(wal);
2366                for s in &spilled {
2367                    if let Some(parent) = s.pending_path.parent() {
2368                        let _ = std::fs::remove_dir_all(parent);
2369                    }
2370                }
2371                for (_, _, pending) in &prepared_external {
2372                    let _ = std::fs::remove_file(pending);
2373                }
2374                return Err(MongrelError::Conflict(
2375                    "write-write conflict (sequencer delta re-check)".into(),
2376                ));
2377            }
2378
2379            let new_epoch = self.epoch.bump_assigned();
2380            let mut applies: Vec<(u64, Vec<StagedOp>)> = Vec::new();
2381
2382            for (idx, (table_id, staged)) in staging.iter().enumerate() {
2383                // Skip puts for tables that were spilled — their data is in a
2384                // pending run, not in streamed Put records.
2385                if spilled_tables.contains(table_id) && matches!(staged, Staged::Put(_)) {
2386                    continue;
2387                }
2388                let mut ops = Vec::new();
2389                match staged {
2390                    Staged::Put(_) => {
2391                        // Stamp the pre-built row at the real assigned epoch.
2392                        let mut row = prebuilt[idx].take().expect("prebuilt put row");
2393                        row.committed_epoch = new_epoch;
2394                        let payload = bincode::serialize(&vec![row.clone()])
2395                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
2396                        wal.append(
2397                            txn_id,
2398                            *table_id,
2399                            Op::Put {
2400                                table_id: *table_id,
2401                                rows: payload,
2402                            },
2403                        )?;
2404                        ops.push(StagedOp::Put(row));
2405                    }
2406                    Staged::Delete(rid) => {
2407                        wal.append(
2408                            txn_id,
2409                            *table_id,
2410                            Op::Delete {
2411                                table_id: *table_id,
2412                                row_ids: vec![*rid],
2413                            },
2414                        )?;
2415                        ops.push(StagedOp::Delete(*rid));
2416                    }
2417                    Staged::Truncate => {
2418                        wal.append(
2419                            txn_id,
2420                            *table_id,
2421                            Op::TruncateTable {
2422                                table_id: *table_id,
2423                            },
2424                        )?;
2425                        ops.push(StagedOp::Truncate);
2426                    }
2427                }
2428                applies.push((*table_id, ops));
2429            }
2430
2431            for (name, state, _) in &prepared_external {
2432                wal.append(
2433                    txn_id,
2434                    EXTERNAL_TABLE_ID,
2435                    Op::ExternalTableState {
2436                        name: name.clone(),
2437                        state: state.clone(),
2438                    },
2439                )?;
2440            }
2441
2442            let commit_seq = wal.append_commit(txn_id, new_epoch, &added_runs)?;
2443
2444            // Record the conflict + assign the epoch under the WAL lock so commit
2445            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
2446            // moves out of this critical section to the group-commit coordinator
2447            // so concurrent committers share a single leader fsync.
2448            self.conflicts.record(&write_keys, new_epoch);
2449            (new_epoch, applies, commit_seq)
2450        };
2451
2452        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
2453        self.group
2454            .await_durable(&self.shared_wal, commit_seq)
2455            .inspect_err(|_| {
2456                self.poisoned.store(true, Ordering::Relaxed);
2457            })?;
2458
2459        // ── 3. Publish: link spilled runs + apply non-spilled ops ──
2460        {
2461            let tables = self.tables.read();
2462            // Link spilled runs first.
2463            for s in &spilled {
2464                if let Some(handle) = tables.get(&s.table_id) {
2465                    let mut t = handle.lock();
2466                    let dest = t.run_path(s.run_id as u64);
2467                    std::fs::rename(&s.pending_path, &dest)?;
2468                    // Clean up the now-empty `_txn/<txn_id>/` dir.
2469                    if let Some(parent) = s.pending_path.parent() {
2470                        let _ = std::fs::remove_dir_all(parent);
2471                    }
2472                    t.link_run(crate::manifest::RunRef {
2473                        run_id: s.run_id,
2474                        level: 0,
2475                        epoch_created: new_epoch.0,
2476                        row_count: s.row_count,
2477                    });
2478                    // Update indexes + live_count from the spilled rows WITHOUT
2479                    // materializing them in the memtable (P3.4): they are served
2480                    // from the linked uniform-epoch run, read at `new_epoch` via
2481                    // the RunRef. This keeps peak memory bounded for large txns.
2482                    t.apply_run_metadata(&s.rows)?;
2483                    t.invalidate_pending_cache();
2484                    t.persist_manifest(new_epoch)?;
2485                }
2486            }
2487            // Apply non-spilled ops.
2488            for (table_id, ops) in applies {
2489                if let Some(handle) = tables.get(&table_id) {
2490                    let mut t = handle.lock();
2491                    for op in ops {
2492                        match op {
2493                            StagedOp::Put(row) => t.apply_put_rows(vec![row])?,
2494                            StagedOp::Delete(rid) => t.apply_delete(rid, new_epoch),
2495                            StagedOp::Truncate => t.apply_truncate(new_epoch)?,
2496                        }
2497                    }
2498                    t.invalidate_pending_cache();
2499                    t.persist_manifest(new_epoch)?;
2500                }
2501            }
2502        }
2503        for (name, _, pending) in &prepared_external {
2504            publish_external_state_file(&self.root, name, pending)?;
2505        }
2506
2507        self.advance_visible(new_epoch);
2508        Ok(new_epoch)
2509    }
2510
2511    /// Advance `visible` in-order: epoch E becomes visible only once E and all
2512    /// prior unpublished epochs have finished publishing (spec §9.3e). The
2513    /// in-order gate lives on the shared [`EpochAuthority`] so this path, the
2514    /// single-table `Table::commit` path, and DDL all share one watermark and
2515    /// can never publish out of assigned order under concurrency.
2516    fn advance_visible(&self, published: Epoch) {
2517        self.epoch.publish_in_order(published);
2518    }
2519
2520    /// Register a read snapshot at the current visible epoch and return it with
2521    /// a guard that retains it for GC until dropped.
2522    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
2523        let e = self.epoch.visible();
2524        let g = self.snapshots.register(e);
2525        (Snapshot::at(e), g)
2526    }
2527
2528    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
2529    /// retention.
2530    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
2531        let e = self.epoch.visible();
2532        let g = self.snapshots.register_owned(e);
2533        (Snapshot::at(e), g)
2534    }
2535
2536    /// Names of all live tables.
2537    pub fn table_names(&self) -> Vec<String> {
2538        self.catalog
2539            .read()
2540            .tables
2541            .iter()
2542            .filter(|t| matches!(t.state, TableState::Live))
2543            .map(|t| t.name.clone())
2544            .collect()
2545    }
2546
2547    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
2548    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
2549    /// reaped on the next open. Call this as the last action before a
2550    /// short-lived process (CLI, one-shot script) exits. The daemon does not
2551    /// need this — its background auto-compactor handles run management.
2552    pub fn close(&self) -> Result<()> {
2553        for name in self.table_names() {
2554            if let Ok(handle) = self.table(&name) {
2555                if let Err(e) = handle.lock().close() {
2556                    eprintln!("[close] flush failed for {name}: {e}");
2557                }
2558            }
2559        }
2560        Ok(())
2561    }
2562
2563    /// Compact every mounted table: merge all sorted runs into one clean run
2564    /// so query cost stays flat (single-run fast path) instead of growing
2565    /// with run count. Tables with < 2 runs are skipped (no-op). Each table
2566    /// is locked individually for its own compaction; snapshot retention is
2567    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
2568    pub fn compact(&self) -> Result<(usize, usize)> {
2569        let mut compacted = 0;
2570        let mut skipped = 0;
2571        for name in self.table_names() {
2572            let Ok(handle) = self.table(&name) else {
2573                continue;
2574            };
2575            {
2576                let mut t = handle.lock();
2577                let before = t.run_count();
2578                if before < 2 {
2579                    skipped += 1;
2580                    continue;
2581                }
2582                match t.compact() {
2583                    Ok(()) => {
2584                        let after = t.run_count();
2585                        compacted += 1;
2586                        eprintln!("[compact] {name}: {before} -> {after} runs");
2587                    }
2588                    Err(e) => {
2589                        eprintln!("[compact] {name}: compaction failed: {e}");
2590                        skipped += 1;
2591                    }
2592                }
2593            }
2594        }
2595        Ok((compacted, skipped))
2596    }
2597
2598    /// Compact a single table by name. Returns `Ok(true)` if it was
2599    /// compacted, `Ok(false)` if skipped (< 2 runs).
2600    pub fn compact_table(&self, name: &str) -> Result<bool> {
2601        let handle = self.table(name)?;
2602        let mut t = handle.lock();
2603        let before = t.run_count();
2604        if before < 2 {
2605            return Ok(false);
2606        }
2607        t.compact()?;
2608        Ok(t.run_count() < before)
2609    }
2610
2611    /// Look up a live table by name.
2612    pub fn table(&self, name: &str) -> Result<TableHandle> {
2613        let cat = self.catalog.read();
2614        let entry = cat
2615            .live(name)
2616            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
2617        let id = entry.table_id;
2618        drop(cat);
2619        self.tables
2620            .read()
2621            .get(&id)
2622            .cloned()
2623            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
2624    }
2625
2626    /// Resolve a live table id → mounted handle (used by the constraint
2627    /// validation pass and other id-qualified internal paths).
2628    fn table_by_id(&self, id: u64) -> Result<TableHandle> {
2629        self.tables
2630            .read()
2631            .get(&id)
2632            .cloned()
2633            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
2634    }
2635
2636    /// Create a new table. The DDL is first logged to the shared WAL
2637    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
2638    /// BEFORE the in-memory catalog and table map are mutated; the catalog
2639    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
2640    /// that sees a stale catalog still recovers the table by replaying the Ddl.
2641    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
2642        use crate::wal::DdlOp;
2643        use std::sync::atomic::Ordering;
2644
2645        if self.poisoned.load(Ordering::Relaxed) {
2646            return Err(MongrelError::Other(
2647                "database poisoned by fsync error".into(),
2648            ));
2649        }
2650
2651        let _g = self.ddl_lock.lock();
2652        {
2653            let cat = self.catalog.read();
2654            if cat.live(name).is_some() {
2655                return Err(MongrelError::InvalidArgument(format!(
2656                    "table {name:?} already exists"
2657                )));
2658            }
2659        }
2660
2661        // Allocate id + epoch + txn id under the commit lock so the DDL commit
2662        // is serialized with data commits (in-order publish).
2663        let commit_lock = Arc::clone(&self.commit_lock);
2664        let _c = commit_lock.lock();
2665        let table_id = {
2666            let mut cat = self.catalog.write();
2667            let id = cat.next_table_id;
2668            cat.next_table_id += 1;
2669            id
2670        };
2671        let epoch = self.epoch.bump_assigned();
2672        let txn_id = self.alloc_txn_id();
2673
2674        // Stamp the schema_id with the unique table_id so every table in the
2675        // database has a distinct schema_id (caller-provided values are
2676        // ignored to prevent collisions).
2677        let mut schema = schema;
2678        schema.schema_id = table_id;
2679        // Defense in depth: reject an invalid schema BEFORE any durable
2680        // side-effect. `Table::create_in` re-validates, but by then the DDL has
2681        // already been appended to the shared WAL; a failing create_in would
2682        // leave a dangling entry that `recover_ddl_from_wal` replays without
2683        // re-validating, corrupting the catalog on reopen. Validating here
2684        // keeps the WAL free of schemas that can never be opened.
2685        schema.validate_auto_increment()?;
2686
2687        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
2688        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
2689        let schema_json = DdlOp::encode_schema(&schema)?;
2690        let commit_seq = {
2691            let mut wal = self.shared_wal.lock();
2692            wal.append(
2693                txn_id,
2694                table_id,
2695                crate::wal::Op::Ddl(DdlOp::CreateTable {
2696                    table_id,
2697                    name: name.to_string(),
2698                    schema_json,
2699                }),
2700            )?;
2701            wal.append_commit(txn_id, epoch, &[])?
2702        };
2703        self.group
2704            .await_durable(&self.shared_wal, commit_seq)
2705            .inspect_err(|_| {
2706                self.poisoned.store(true, Ordering::Relaxed);
2707            })?;
2708
2709        // 2. Create the on-disk table dir + manifest.
2710        let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
2711        std::fs::create_dir_all(&tdir)?;
2712        let ctx = SharedCtx {
2713            epoch: Arc::clone(&self.epoch),
2714            page_cache: Arc::clone(&self.page_cache),
2715            decoded_cache: Arc::clone(&self.decoded_cache),
2716            snapshots: Arc::clone(&self.snapshots),
2717            kek: self.kek.clone(),
2718            commit_lock: Arc::clone(&self.commit_lock),
2719            shared: Some(crate::engine::SharedWalCtx {
2720                wal: Arc::clone(&self.shared_wal),
2721                group: Arc::clone(&self.group),
2722                poisoned: Arc::clone(&self.poisoned),
2723                txn_ids: Arc::clone(&self.next_txn_id),
2724            }),
2725        };
2726        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
2727
2728        // 3. Mutate the in-memory catalog + mount the table, then rewrite the
2729        //    catalog checkpoint (lazy: outside the WAL critical section).
2730        {
2731            let mut cat = self.catalog.write();
2732            cat.tables.push(CatalogEntry {
2733                table_id,
2734                name: name.to_string(),
2735                schema,
2736                state: TableState::Live,
2737                created_epoch: epoch.0,
2738            });
2739        }
2740        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2741        self.tables
2742            .write()
2743            .insert(table_id, Arc::new(Mutex::new(table)));
2744
2745        self.advance_visible(epoch);
2746        Ok(table_id)
2747    }
2748
2749    /// Logically drop a table, logging the DDL through the shared WAL first.
2750    pub fn drop_table(&self, name: &str) -> Result<()> {
2751        use crate::wal::DdlOp;
2752        use std::sync::atomic::Ordering;
2753
2754        if self.poisoned.load(Ordering::Relaxed) {
2755            return Err(MongrelError::Other(
2756                "database poisoned by fsync error".into(),
2757            ));
2758        }
2759
2760        let _g = self.ddl_lock.lock();
2761        let table_id = {
2762            let cat = self.catalog.read();
2763            cat.live(name)
2764                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
2765                .table_id
2766        };
2767
2768        let commit_lock = Arc::clone(&self.commit_lock);
2769        let _c = commit_lock.lock();
2770        let epoch = self.epoch.bump_assigned();
2771        let txn_id = self.alloc_txn_id();
2772        let commit_seq = {
2773            let mut wal = self.shared_wal.lock();
2774            wal.append(
2775                txn_id,
2776                table_id,
2777                crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
2778            )?;
2779            wal.append_commit(txn_id, epoch, &[])?
2780        };
2781        self.group
2782            .await_durable(&self.shared_wal, commit_seq)
2783            .inspect_err(|_| {
2784                self.poisoned.store(true, Ordering::Relaxed);
2785            })?;
2786
2787        {
2788            let mut cat = self.catalog.write();
2789            let entry = cat
2790                .tables
2791                .iter_mut()
2792                .find(|t| t.table_id == table_id)
2793                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
2794            entry.state = TableState::Dropped { at_epoch: epoch.0 };
2795            cat.triggers.retain(|trigger| {
2796                !matches!(
2797                    &trigger.trigger.target,
2798                    TriggerTarget::Table(target) if target == name
2799                )
2800            });
2801        }
2802        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2803        self.tables.write().remove(&table_id);
2804
2805        self.advance_visible(epoch);
2806        Ok(())
2807    }
2808
2809    /// Rename a live table. `name` must exist and `new_name` must not collide
2810    /// with any live table; both checks run under `ddl_lock` so they are atomic
2811    /// with the rename and with concurrent `create_table` existence checks (no
2812    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
2813    /// side-effects. The rename is logged to the shared WAL as
2814    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
2815    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
2816    /// the in-memory object does not move — only the catalog name changes).
2817    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
2818        use crate::wal::DdlOp;
2819        use std::sync::atomic::Ordering;
2820
2821        if self.poisoned.load(Ordering::Relaxed) {
2822            return Err(MongrelError::Other(
2823                "database poisoned by fsync error".into(),
2824            ));
2825        }
2826
2827        // A no-op rename short-circuits before any locking, so it can never
2828        // trip the "target already exists" check (the source *is* that name).
2829        if name == new_name {
2830            return Ok(());
2831        }
2832        if new_name.is_empty() {
2833            return Err(MongrelError::InvalidArgument(
2834                "rename_table: new name must not be empty".into(),
2835            ));
2836        }
2837
2838        let _g = self.ddl_lock.lock();
2839        let table_id = {
2840            let cat = self.catalog.read();
2841            let src = cat
2842                .live(name)
2843                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
2844            // Target must be free. Checked under ddl_lock, which every other
2845            // DDL (create/rename/drop) also holds, so a concurrent operation
2846            // cannot claim `new_name` between this check and the catalog write.
2847            if cat.live(new_name).is_some() {
2848                return Err(MongrelError::InvalidArgument(format!(
2849                    "rename_table: a table named {new_name:?} already exists"
2850                )));
2851            }
2852            src.table_id
2853        };
2854
2855        let commit_lock = Arc::clone(&self.commit_lock);
2856        let _c = commit_lock.lock();
2857        let epoch = self.epoch.bump_assigned();
2858        let txn_id = self.alloc_txn_id();
2859        let commit_seq = {
2860            let mut wal = self.shared_wal.lock();
2861            wal.append(
2862                txn_id,
2863                table_id,
2864                crate::wal::Op::Ddl(DdlOp::RenameTable {
2865                    table_id,
2866                    new_name: new_name.to_string(),
2867                }),
2868            )?;
2869            wal.append_commit(txn_id, epoch, &[])?
2870        };
2871        self.group
2872            .await_durable(&self.shared_wal, commit_seq)
2873            .inspect_err(|_| {
2874                self.poisoned.store(true, Ordering::Relaxed);
2875            })?;
2876
2877        {
2878            let mut cat = self.catalog.write();
2879            let entry = cat
2880                .tables
2881                .iter_mut()
2882                .find(|t| t.table_id == table_id)
2883                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
2884            entry.name = new_name.to_string();
2885            for trigger in &mut cat.triggers {
2886                if matches!(
2887                    &trigger.trigger.target,
2888                    TriggerTarget::Table(target) if target == name
2889                ) {
2890                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
2891                }
2892            }
2893        }
2894        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2895        // The in-memory table object is keyed by table_id, not name, so it does
2896        // not move and live TableHandles remain valid.
2897        self.advance_visible(epoch);
2898        Ok(())
2899    }
2900
2901    pub fn alter_column(
2902        &self,
2903        table_name: &str,
2904        column_name: &str,
2905        change: AlterColumn,
2906    ) -> Result<ColumnDef> {
2907        use crate::wal::DdlOp;
2908        use std::sync::atomic::Ordering;
2909
2910        if self.poisoned.load(Ordering::Relaxed) {
2911            return Err(MongrelError::Other(
2912                "database poisoned by fsync error".into(),
2913            ));
2914        }
2915
2916        let _g = self.ddl_lock.lock();
2917        let table_id = {
2918            let cat = self.catalog.read();
2919            cat.live(table_name)
2920                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
2921                .table_id
2922        };
2923        let handle =
2924            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
2925                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
2926            })?;
2927        let mut table = handle.lock();
2928        let column = table.prepare_alter_column(column_name, &change)?;
2929        let renamed_column = (column.name != column_name).then(|| column.name.clone());
2930        if table
2931            .schema()
2932            .columns
2933            .iter()
2934            .find(|c| c.id == column.id)
2935            .is_some_and(|c| c == &column)
2936        {
2937            return Ok(column);
2938        }
2939
2940        let commit_lock = Arc::clone(&self.commit_lock);
2941        let _c = commit_lock.lock();
2942        let epoch = self.epoch.bump_assigned();
2943        let txn_id = self.alloc_txn_id();
2944        let column_json = DdlOp::encode_column(&column)?;
2945        let commit_seq = {
2946            let mut wal = self.shared_wal.lock();
2947            wal.append(
2948                txn_id,
2949                table_id,
2950                crate::wal::Op::Ddl(DdlOp::AlterTable {
2951                    table_id,
2952                    column_json,
2953                }),
2954            )?;
2955            wal.append_commit(txn_id, epoch, &[])?
2956        };
2957        self.group
2958            .await_durable(&self.shared_wal, commit_seq)
2959            .inspect_err(|_| {
2960                self.poisoned.store(true, Ordering::Relaxed);
2961            })?;
2962
2963        table.apply_altered_column(column.clone())?;
2964        let schema = table.schema().clone();
2965        drop(table);
2966
2967        {
2968            let mut cat = self.catalog.write();
2969            let entry = cat
2970                .tables
2971                .iter_mut()
2972                .find(|t| t.table_id == table_id)
2973                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
2974            entry.schema = schema;
2975            if let Some(new_column_name) = renamed_column {
2976                for trigger in &mut cat.triggers {
2977                    if matches!(
2978                        &trigger.trigger.target,
2979                        TriggerTarget::Table(target) if target == table_name
2980                    ) {
2981                        trigger.trigger = trigger.trigger.renamed_update_column(
2982                            column_name,
2983                            new_column_name.clone(),
2984                            epoch.0,
2985                        )?;
2986                    }
2987                }
2988            }
2989        }
2990        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2991
2992        self.advance_visible(epoch);
2993        Ok(column)
2994    }
2995
2996    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
2997    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
2998    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
2999    ///
3000    /// Returns the number of items reclaimed.
3001    pub fn gc(&self) -> Result<usize> {
3002        let min_active = self.snapshots.min_active(self.epoch.visible()).0;
3003        let mut reclaimed = 0;
3004
3005        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
3006        let cat = self.catalog.read();
3007        for entry in &cat.tables {
3008            if let TableState::Dropped { at_epoch } = entry.state {
3009                if at_epoch <= min_active {
3010                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
3011                    if tdir.exists() {
3012                        std::fs::remove_dir_all(&tdir)?;
3013                        reclaimed += 1;
3014                    }
3015                }
3016            }
3017        }
3018        drop(cat);
3019
3020        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
3021        // in-flight spill's dir (deleting it would lose the pending run and fail
3022        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
3023        // skip any id still registered in `active_spills`.
3024        let cat = self.catalog.read();
3025        for entry in &cat.tables {
3026            if !matches!(entry.state, TableState::Live) {
3027                continue;
3028            }
3029            let txn_dir = self
3030                .root
3031                .join(TABLES_DIR)
3032                .join(entry.table_id.to_string())
3033                .join("_txn");
3034            if !txn_dir.exists() {
3035                continue;
3036            }
3037            for sub in std::fs::read_dir(&txn_dir)? {
3038                let sub = sub?;
3039                let name = sub.file_name();
3040                let Some(name) = name.to_str() else { continue };
3041                // A non-numeric entry can't belong to a live txn — sweep it.
3042                let is_active = name
3043                    .parse::<u64>()
3044                    .map(|id| self.active_spills.is_active(id))
3045                    .unwrap_or(false);
3046                if is_active {
3047                    continue;
3048                }
3049                std::fs::remove_dir_all(sub.path())?;
3050                reclaimed += 1;
3051            }
3052        }
3053        drop(cat);
3054
3055        let external_names = {
3056            let cat = self.catalog.read();
3057            cat.external_tables
3058                .iter()
3059                .map(|entry| entry.name.clone())
3060                .collect::<std::collections::HashSet<_>>()
3061        };
3062        let vtab_dir = self.root.join(VTAB_DIR);
3063        if vtab_dir.exists() {
3064            for entry in std::fs::read_dir(&vtab_dir)? {
3065                let entry = entry?;
3066                let name = entry.file_name();
3067                let Some(name) = name.to_str() else { continue };
3068                if external_names.contains(name) {
3069                    continue;
3070                }
3071                let path = entry.path();
3072                if path.is_dir() {
3073                    std::fs::remove_dir_all(path)?;
3074                } else {
3075                    std::fs::remove_file(path)?;
3076                }
3077                reclaimed += 1;
3078            }
3079        }
3080
3081        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
3082        // can still need (spec §6.4). Each table deletes its own retired files
3083        // gated on `min_active` and persists its manifest.
3084        let tables = self.tables.read();
3085        for handle in tables.values() {
3086            reclaimed += handle.lock().reap_retiring(Epoch(min_active))?;
3087        }
3088
3089        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
3090        // segment on every reopen without truncating the prior ones, so rotated
3091        // segments accumulate. Once every live table's committed data is durable
3092        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
3093        // (non-active) segments are redundant for recovery and safe to delete —
3094        // an in-flight txn only ever appends to the active segment, which is
3095        // never deleted.
3096        let all_durable = self.active_spills.is_idle()
3097            && tables.values().all(|h| {
3098                let g = h.lock();
3099                g.memtable_len() == 0 && g.mutable_run_len() == 0
3100            });
3101        drop(tables);
3102        if all_durable {
3103            reclaimed += self.shared_wal.lock().gc_segments(u64::MAX)?;
3104        }
3105
3106        Ok(reclaimed)
3107    }
3108    fn alloc_txn_id(&self) -> u64 {
3109        let mut g = self.next_txn_id.lock();
3110        let id = *g;
3111        *g = g.wrapping_add(1);
3112        id
3113    }
3114
3115    /// Set the per-table spill threshold (bytes). When a transaction's staged
3116    /// bytes for a single table exceed this, the rows are written as a
3117    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
3118    pub fn set_spill_threshold(&self, bytes: u64) {
3119        self.spill_threshold
3120            .store(bytes, std::sync::atomic::Ordering::Relaxed);
3121    }
3122
3123    /// Test-only: install a hook invoked after a transaction writes its spill
3124    /// runs but before the sequencer, so a test can race `gc()` against an
3125    /// in-flight spill. Not part of the stable API.
3126    #[doc(hidden)]
3127    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
3128        *self.spill_hook.lock() = Some(Box::new(f));
3129    }
3130
3131    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
3132    /// this stays well below the number of committed transactions when commits
3133    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
3134    #[doc(hidden)]
3135    pub fn __wal_group_sync_count(&self) -> u64 {
3136        self.shared_wal.lock().group_sync_count()
3137    }
3138
3139    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
3140    /// contract that an fsync error would trigger in production.
3141    #[doc(hidden)]
3142    pub fn __poison(&self) {
3143        self.poisoned
3144            .store(true, std::sync::atomic::Ordering::Relaxed);
3145    }
3146
3147    /// Verify multi-table integrity (spec §16). For every live table this:
3148    /// authenticates the manifest; opens each `RunRef`'s file through
3149    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
3150    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
3151    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
3152    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
3153    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
3154    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
3155    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
3156    ///
3157    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
3158    /// full body, so this is an integrity tool, not a hot path.
3159    pub fn check(&self) -> Vec<CheckIssue> {
3160        let mut issues = Vec::new();
3161        let cat = self.catalog.read();
3162        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
3163        for entry in &cat.tables {
3164            if !matches!(entry.state, TableState::Live) {
3165                continue;
3166            }
3167            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
3168            let mut err = |sev: &str, desc: String| {
3169                issues.push(CheckIssue {
3170                    table_id: entry.table_id,
3171                    table_name: entry.name.clone(),
3172                    severity: sev.into(),
3173                    description: desc,
3174                });
3175            };
3176            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
3177                Ok(m) => m,
3178                Err(e) => {
3179                    err("error", format!("manifest read failed: {e}"));
3180                    continue;
3181                }
3182            };
3183            if m.flushed_epoch > m.current_epoch {
3184                err(
3185                    "error",
3186                    format!(
3187                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
3188                        m.flushed_epoch, m.current_epoch
3189                    ),
3190                );
3191            }
3192
3193            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
3194            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
3195            for rr in &m.runs {
3196                referenced.insert(rr.run_id);
3197                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
3198                if !run_path.exists() {
3199                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
3200                    continue;
3201                }
3202                match crate::sorted_run::RunReader::open(
3203                    &run_path,
3204                    entry.schema.clone(),
3205                    self.kek.clone(),
3206                ) {
3207                    Ok(reader) => {
3208                        if reader.row_count() as u64 != rr.row_count {
3209                            err(
3210                                "error",
3211                                format!(
3212                                    "run r-{} row count mismatch: manifest {} vs run {}",
3213                                    rr.run_id,
3214                                    rr.row_count,
3215                                    reader.row_count()
3216                                ),
3217                            );
3218                        }
3219                    }
3220                    Err(e) => {
3221                        err(
3222                            "error",
3223                            format!("run r-{} integrity check failed: {e}", rr.run_id),
3224                        );
3225                    }
3226                }
3227            }
3228
3229            // Compaction-superseded runs awaiting retention-gated deletion are
3230            // tracked in `retiring`; their files are expected on disk, so they
3231            // are not orphans.
3232            for r in &m.retiring {
3233                referenced.insert(r.run_id);
3234            }
3235
3236            // Orphan `.sr` files present on disk but absent from the manifest.
3237            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
3238                for ent in rd.flatten() {
3239                    let p = ent.path();
3240                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
3241                        continue;
3242                    }
3243                    let run_id = p
3244                        .file_stem()
3245                        .and_then(|s| s.to_str())
3246                        .and_then(|s| s.strip_prefix("r-"))
3247                        .and_then(|s| s.parse::<u128>().ok());
3248                    if let Some(id) = run_id {
3249                        if !referenced.contains(&id) {
3250                            err(
3251                                "warning",
3252                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
3253                            );
3254                        }
3255                    }
3256                }
3257            }
3258        }
3259
3260        let external_names = cat
3261            .external_tables
3262            .iter()
3263            .map(|entry| entry.name.clone())
3264            .collect::<std::collections::HashSet<_>>();
3265        let vtab_dir = self.root.join(VTAB_DIR);
3266        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
3267            for entry in entries.flatten() {
3268                let name = entry.file_name();
3269                let Some(name) = name.to_str() else { continue };
3270                if !external_names.contains(name) {
3271                    issues.push(CheckIssue {
3272                        table_id: EXTERNAL_TABLE_ID,
3273                        table_name: name.to_string(),
3274                        severity: "warning".into(),
3275                        description: format!(
3276                            "orphan external table state entry {:?} not referenced by the catalog",
3277                            entry.path()
3278                        ),
3279                    });
3280                }
3281            }
3282        }
3283
3284        // WAL retention / integrity invariant (spec §16): every on-disk WAL
3285        // segment must open (header magic + version, and the frame cipher must
3286        // be derivable for an encrypted WAL). A segment that won't open is
3287        // corrupt or truncated and would break crash recovery. `table_id` is
3288        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
3289        // never confuses a WAL issue with a real table.
3290        for (seg, msg) in self.shared_wal.lock().verify_segments() {
3291            issues.push(CheckIssue {
3292                table_id: WAL_TABLE_ID,
3293                table_name: "<wal>".into(),
3294                severity: "error".into(),
3295                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
3296            });
3297        }
3298        issues
3299    }
3300
3301    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
3302    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
3303    /// unmounts them from the live table map so the DB still opens.
3304    pub fn doctor(&self) -> Result<Vec<u64>> {
3305        // Hold the DDL lock for the whole operation to prevent concurrent
3306        // create_table/drop_table from racing the catalog/dir mutation.
3307        let _ddl = self.ddl_lock.lock();
3308        let issues = self.check();
3309        // A corrupt WAL segment is reported as an error but is NOT a table
3310        // problem — quarantining an innocent table cannot fix it (and the first
3311        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
3312        // them disjoint). The admin must address WAL corruption manually.
3313        let bad_tables: std::collections::HashSet<u64> = issues
3314            .iter()
3315            .filter(|i| {
3316                i.severity == "error"
3317                    && i.table_id != WAL_TABLE_ID
3318                    && i.table_id != EXTERNAL_TABLE_ID
3319            })
3320            .map(|i| i.table_id)
3321            .collect();
3322        if bad_tables.is_empty() {
3323            return Ok(Vec::new());
3324        }
3325
3326        let qdir = self.root.join("_quarantine");
3327        std::fs::create_dir_all(&qdir)?;
3328        let mut quarantined = Vec::new();
3329        for &table_id in &bad_tables {
3330            let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
3331            if tdir.exists() {
3332                let dest = qdir.join(table_id.to_string());
3333                std::fs::rename(&tdir, &dest)?;
3334            }
3335            {
3336                let mut cat = self.catalog.write();
3337                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
3338                    entry.state = TableState::Dropped {
3339                        at_epoch: self.epoch.visible().0,
3340                    };
3341                }
3342            }
3343            // Unmount the live handle so no further access reaches the moved dir.
3344            self.tables.write().remove(&table_id);
3345            quarantined.push(table_id);
3346        }
3347        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3348        Ok(quarantined)
3349    }
3350
3351    /// The DB-wide KEK (if encrypted).
3352    #[allow(dead_code)]
3353    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
3354        self.kek.as_ref()
3355    }
3356
3357    /// Shared epoch authority (used by the transaction layer in P2).
3358    #[allow(dead_code)]
3359    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
3360        &self.epoch
3361    }
3362
3363    /// Shared snapshot registry (used by GC in P3.6).
3364    #[allow(dead_code)]
3365    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
3366        &self.snapshots
3367    }
3368}
3369
3370fn external_state_dir(root: &Path, name: &str) -> PathBuf {
3371    root.join(VTAB_DIR).join(name)
3372}
3373
3374fn filter_ignored_staging(
3375    staging: Vec<(u64, crate::txn::Staged)>,
3376    ignored_indices: &std::collections::BTreeSet<usize>,
3377) -> Vec<(u64, crate::txn::Staged)> {
3378    if ignored_indices.is_empty() {
3379        return staging;
3380    }
3381    staging
3382        .into_iter()
3383        .enumerate()
3384        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
3385        .collect()
3386}
3387
3388fn external_state_file(root: &Path, name: &str) -> PathBuf {
3389    external_state_dir(root, name).join("state.json")
3390}
3391
3392fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
3393    let path = external_state_file(root, name);
3394    match std::fs::read(path) {
3395        Ok(bytes) => Ok(bytes),
3396        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
3397        Err(e) => Err(e.into()),
3398    }
3399}
3400
3401fn current_external_state_bytes(
3402    root: &Path,
3403    external_states: &[(String, Vec<u8>)],
3404    name: &str,
3405) -> Result<Vec<u8>> {
3406    for (table, state) in external_states.iter().rev() {
3407        if table == name {
3408            return Ok(state.clone());
3409        }
3410    }
3411    read_external_state_file(root, name)
3412}
3413
3414fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
3415    let mut out = external_states;
3416    dedup_external_states_in_place(&mut out);
3417    out
3418}
3419
3420fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
3421    let mut seen = std::collections::HashSet::new();
3422    let mut out = Vec::with_capacity(external_states.len());
3423    for (name, state) in std::mem::take(external_states).into_iter().rev() {
3424        if seen.insert(name.clone()) {
3425            out.push((name, state));
3426        }
3427    }
3428    out.reverse();
3429    *external_states = out;
3430}
3431
3432fn prepare_external_state_file(
3433    root: &Path,
3434    name: &str,
3435    state: &[u8],
3436    txn_id: u64,
3437) -> Result<PathBuf> {
3438    let dir = external_state_dir(root, name);
3439    std::fs::create_dir_all(&dir)?;
3440    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
3441    {
3442        let mut file = std::fs::File::create(&pending)?;
3443        file.write_all(state)?;
3444        file.sync_all()?;
3445    }
3446    Ok(pending)
3447}
3448
3449fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
3450    let path = external_state_file(root, name);
3451    std::fs::rename(pending, &path)?;
3452    if let Ok(dir) = std::fs::File::open(external_state_dir(root, name)) {
3453        let _ = dir.sync_all();
3454    }
3455    Ok(())
3456}
3457
3458fn write_external_state_file(root: &Path, name: &str, state: &[u8]) -> Result<()> {
3459    let pending = prepare_external_state_file(root, name, state, 0)?;
3460    publish_external_state_file(root, name, &pending)
3461}
3462
3463/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
3464///
3465/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
3466/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
3467/// 2 applies each committed data record (Put/Delete) to its table at the commit
3468/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
3469/// durable in a sorted run). Finally the shared epoch authority is raised to the
3470/// max committed epoch so the next commit continues monotonically.
3471fn recover_shared_wal(
3472    root: &Path,
3473    tables: &HashMap<u64, TableHandle>,
3474    epoch: &EpochAuthority,
3475    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
3476) -> Result<()> {
3477    use crate::memtable::Row;
3478    use crate::rowid::RowId;
3479    use crate::wal::{Op, SharedWal};
3480
3481    let records = SharedWal::replay_with_dek(root, wal_dek)?;
3482
3483    // Pass 1: committed-txn outcomes + collect spilled-run info.
3484    let mut committed: HashMap<u64, u64> = HashMap::new();
3485    let mut spilled_to_link: Vec<(
3486        u64, /*txn_id*/
3487        u64, /*epoch*/
3488        Vec<crate::wal::AddedRun>,
3489    )> = Vec::new();
3490    for r in &records {
3491        if let Op::TxnCommit {
3492            epoch: ce,
3493            ref added_runs,
3494        } = r.op
3495        {
3496            committed.insert(r.txn_id, ce);
3497            if !added_runs.is_empty() {
3498                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
3499            }
3500        }
3501    }
3502
3503    // Pass 2: stage data per table, gated by flushed_epoch.
3504    type TableStage = (Vec<Row>, Vec<(RowId, Epoch)>, Option<Epoch>, Epoch);
3505    let mut stage: HashMap<u64, TableStage> = HashMap::new();
3506    let mut max_epoch = epoch.visible().0;
3507    for r in records {
3508        let Some(&ce) = committed.get(&r.txn_id) else {
3509            continue; // aborted / in-flight — discard
3510        };
3511        let commit_epoch = Epoch(ce);
3512        max_epoch = max_epoch.max(ce);
3513        match r.op {
3514            Op::Put { table_id, rows } => {
3515                // Skip if this table already flushed past the commit epoch.
3516                let skip = tables
3517                    .get(&table_id)
3518                    .map(|h| h.lock().flushed_epoch() >= ce)
3519                    .unwrap_or(true);
3520                if skip {
3521                    continue;
3522                }
3523                let rows: Vec<Row> = match bincode::deserialize(&rows) {
3524                    Ok(v) => v,
3525                    Err(_) => continue,
3526                };
3527                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
3528                // at pending_epoch which equals the commit epoch, but be robust).
3529                let rows: Vec<Row> = rows
3530                    .into_iter()
3531                    .map(|mut row| {
3532                        row.committed_epoch = commit_epoch;
3533                        row
3534                    })
3535                    .collect();
3536                let entry = stage
3537                    .entry(table_id)
3538                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
3539                entry.0.extend(rows);
3540                entry.3 = commit_epoch;
3541            }
3542            Op::Delete { table_id, row_ids } => {
3543                let skip = tables
3544                    .get(&table_id)
3545                    .map(|h| h.lock().flushed_epoch() >= ce)
3546                    .unwrap_or(true);
3547                if skip {
3548                    continue;
3549                }
3550                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
3551                let entry = stage
3552                    .entry(table_id)
3553                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
3554                entry.1.extend(dels);
3555                entry.3 = commit_epoch;
3556            }
3557            Op::TruncateTable { table_id } => {
3558                let skip = tables
3559                    .get(&table_id)
3560                    .map(|h| h.lock().flushed_epoch() >= ce)
3561                    .unwrap_or(true);
3562                if skip {
3563                    continue;
3564                }
3565                stage.insert(
3566                    table_id,
3567                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
3568                );
3569            }
3570            Op::ExternalTableState { name, state } => {
3571                write_external_state_file(root, &name, &state)?;
3572            }
3573            Op::Flush { .. } | Op::TxnCommit { .. } | Op::TxnAbort | Op::Ddl(_) => {}
3574        }
3575    }
3576    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
3577        let Some(handle) = tables.get(&table_id) else {
3578            continue;
3579        };
3580        let mut t = handle.lock();
3581        if let Some(epoch) = truncate_epoch {
3582            t.apply_truncate(epoch)?;
3583        }
3584        t.recover_apply(rows, deletes)?;
3585        if truncate_epoch.is_some() {
3586            let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
3587            t.live_count = rows.len() as u64;
3588            t.persist_manifest(table_epoch)?;
3589        }
3590    }
3591
3592    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
3593    // between TxnCommit sync and the publish phase leaves the run in
3594    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
3595    for (txn_id, ce, added_runs) in &spilled_to_link {
3596        for ar in added_runs {
3597            let Some(handle) = tables.get(&ar.table_id) else {
3598                continue;
3599            };
3600            let mut t = handle.lock();
3601            let dest = t.run_path(ar.run_id as u64);
3602            if !dest.exists() {
3603                let pending = root
3604                    .join(TABLES_DIR)
3605                    .join(ar.table_id.to_string())
3606                    .join("_txn")
3607                    .join(txn_id.to_string())
3608                    .join(format!("r-{}.sr", ar.run_id));
3609                if pending.exists() {
3610                    if let Some(parent) = pending.parent() {
3611                        std::fs::rename(&pending, &dest)?;
3612                        let _ = std::fs::remove_dir_all(parent);
3613                    }
3614                }
3615            }
3616            // Only link a run whose file is actually present, and never re-link
3617            // one the publish phase already persisted into the manifest (which is
3618            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
3619            // until segment GC). `recover_spilled_run` is idempotent + reconciles
3620            // `live_count`/indexes only when the run is genuinely new.
3621            if t.run_path(ar.run_id as u64).exists() {
3622                t.recover_spilled_run(crate::manifest::RunRef {
3623                    run_id: ar.run_id,
3624                    level: ar.level,
3625                    epoch_created: *ce,
3626                    row_count: ar.row_count,
3627                });
3628            }
3629        }
3630    }
3631
3632    epoch.advance_recovered(Epoch(max_epoch));
3633    Ok(())
3634}
3635
3636fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
3637    match condition {
3638        ProcedureCondition::Pk { .. } => {
3639            if schema.primary_key().is_none() {
3640                return Err(MongrelError::InvalidArgument(
3641                    "procedure condition Pk references a table without a primary key".into(),
3642                ));
3643            }
3644        }
3645        ProcedureCondition::BitmapEq { column_id, .. }
3646        | ProcedureCondition::BitmapIn { column_id, .. }
3647        | ProcedureCondition::Range { column_id, .. }
3648        | ProcedureCondition::RangeF64 { column_id, .. }
3649        | ProcedureCondition::IsNull { column_id }
3650        | ProcedureCondition::IsNotNull { column_id }
3651        | ProcedureCondition::FmContains { column_id, .. } => {
3652            validate_column_id(*column_id, schema)?;
3653        }
3654    }
3655    Ok(())
3656}
3657
3658fn bind_procedure_args(
3659    procedure: &StoredProcedure,
3660    mut args: HashMap<String, crate::Value>,
3661) -> Result<HashMap<String, crate::Value>> {
3662    let mut out = HashMap::new();
3663    for param in &procedure.params {
3664        let value = match args.remove(&param.name) {
3665            Some(value) => value,
3666            None => param.default.clone().ok_or_else(|| {
3667                MongrelError::InvalidArgument(format!(
3668                    "missing required procedure parameter {:?}",
3669                    param.name
3670                ))
3671            })?,
3672        };
3673        if !param.nullable && matches!(value, crate::Value::Null) {
3674            return Err(MongrelError::InvalidArgument(format!(
3675                "procedure parameter {:?} must not be NULL",
3676                param.name
3677            )));
3678        }
3679        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty) {
3680            return Err(MongrelError::InvalidArgument(format!(
3681                "procedure parameter {:?} has wrong type",
3682                param.name
3683            )));
3684        }
3685        out.insert(param.name.clone(), value);
3686    }
3687    if let Some(extra) = args.keys().next() {
3688        return Err(MongrelError::InvalidArgument(format!(
3689            "unknown procedure parameter {extra:?}"
3690        )));
3691    }
3692    Ok(out)
3693}
3694
3695fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
3696    matches!(
3697        (value, ty),
3698        (crate::Value::Bool(_), crate::TypeId::Bool)
3699            | (crate::Value::Int64(_), crate::TypeId::Int8)
3700            | (crate::Value::Int64(_), crate::TypeId::Int16)
3701            | (crate::Value::Int64(_), crate::TypeId::Int32)
3702            | (crate::Value::Int64(_), crate::TypeId::Int64)
3703            | (crate::Value::Int64(_), crate::TypeId::UInt8)
3704            | (crate::Value::Int64(_), crate::TypeId::UInt16)
3705            | (crate::Value::Int64(_), crate::TypeId::UInt32)
3706            | (crate::Value::Int64(_), crate::TypeId::UInt64)
3707            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
3708            | (crate::Value::Int64(_), crate::TypeId::Date32)
3709            | (crate::Value::Float64(_), crate::TypeId::Float32)
3710            | (crate::Value::Float64(_), crate::TypeId::Float64)
3711            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
3712            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
3713    )
3714}
3715
3716fn eval_cells(
3717    cells: &[crate::procedure::ProcedureCell],
3718    args: &HashMap<String, crate::Value>,
3719    outputs: &HashMap<String, ProcedureCallOutput>,
3720) -> Result<Vec<(u16, crate::Value)>> {
3721    cells
3722        .iter()
3723        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
3724        .collect()
3725}
3726
3727fn eval_condition(
3728    condition: &ProcedureCondition,
3729    args: &HashMap<String, crate::Value>,
3730    outputs: &HashMap<String, ProcedureCallOutput>,
3731) -> Result<crate::Condition> {
3732    Ok(match condition {
3733        ProcedureCondition::Pk { value } => {
3734            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
3735        }
3736        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
3737            column_id: *column_id,
3738            value: eval_value(value, args, outputs)?.encode_key(),
3739        },
3740        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
3741            column_id: *column_id,
3742            values: values
3743                .iter()
3744                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
3745                .collect::<Result<Vec<_>>>()?,
3746        },
3747        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
3748            column_id: *column_id,
3749            lo: expect_i64(eval_value(lo, args, outputs)?)?,
3750            hi: expect_i64(eval_value(hi, args, outputs)?)?,
3751        },
3752        ProcedureCondition::RangeF64 {
3753            column_id,
3754            lo,
3755            lo_inclusive,
3756            hi,
3757            hi_inclusive,
3758        } => crate::Condition::RangeF64 {
3759            column_id: *column_id,
3760            lo: expect_f64(eval_value(lo, args, outputs)?)?,
3761            lo_inclusive: *lo_inclusive,
3762            hi: expect_f64(eval_value(hi, args, outputs)?)?,
3763            hi_inclusive: *hi_inclusive,
3764        },
3765        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
3766            column_id: *column_id,
3767        },
3768        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
3769            column_id: *column_id,
3770        },
3771        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
3772            column_id: *column_id,
3773            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
3774        },
3775    })
3776}
3777
3778fn eval_value(
3779    value: &ProcedureValue,
3780    args: &HashMap<String, crate::Value>,
3781    outputs: &HashMap<String, ProcedureCallOutput>,
3782) -> Result<crate::Value> {
3783    match value {
3784        ProcedureValue::Literal(value) => Ok(value.clone()),
3785        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
3786            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
3787        }),
3788        ProcedureValue::StepScalar(id) => match outputs.get(id) {
3789            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
3790            _ => Err(MongrelError::InvalidArgument(format!(
3791                "procedure step {id:?} did not return a scalar"
3792            ))),
3793        },
3794        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
3795            Err(MongrelError::InvalidArgument(
3796                "row-valued procedure reference cannot be used as a scalar".into(),
3797            ))
3798        }
3799        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
3800            "structured procedure value cannot be used as a scalar cell".into(),
3801        )),
3802    }
3803}
3804
3805fn eval_return_output(
3806    value: &ProcedureValue,
3807    args: &HashMap<String, crate::Value>,
3808    outputs: &HashMap<String, ProcedureCallOutput>,
3809) -> Result<ProcedureCallOutput> {
3810    match value {
3811        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
3812        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
3813            args.get(name).cloned().ok_or_else(|| {
3814                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
3815            })?,
3816        )),
3817        ProcedureValue::StepRows(id)
3818        | ProcedureValue::StepRow(id)
3819        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
3820            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
3821        }),
3822        ProcedureValue::Object(fields) => {
3823            let mut out = Vec::with_capacity(fields.len());
3824            for (name, value) in fields {
3825                out.push((name.clone(), eval_return_output(value, args, outputs)?));
3826            }
3827            Ok(ProcedureCallOutput::Object(out))
3828        }
3829        ProcedureValue::Array(values) => {
3830            let mut out = Vec::with_capacity(values.len());
3831            for value in values {
3832                out.push(eval_return_output(value, args, outputs)?);
3833            }
3834            Ok(ProcedureCallOutput::Array(out))
3835        }
3836    }
3837}
3838
3839fn expect_i64(value: crate::Value) -> Result<i64> {
3840    match value {
3841        crate::Value::Int64(value) => Ok(value),
3842        _ => Err(MongrelError::InvalidArgument(
3843            "procedure value must be Int64".into(),
3844        )),
3845    }
3846}
3847
3848fn expect_f64(value: crate::Value) -> Result<f64> {
3849    match value {
3850        crate::Value::Float64(value) => Ok(value),
3851        _ => Err(MongrelError::InvalidArgument(
3852            "procedure value must be Float64".into(),
3853        )),
3854    }
3855}
3856
3857fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
3858    match value {
3859        crate::Value::Bytes(value) => Ok(value),
3860        _ => Err(MongrelError::InvalidArgument(
3861            "procedure value must be Bytes".into(),
3862        )),
3863    }
3864}
3865
3866fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
3867    if schema.columns.iter().any(|c| c.id == column_id) {
3868        Ok(())
3869    } else {
3870        Err(MongrelError::InvalidArgument(format!(
3871            "unknown column id {column_id}"
3872        )))
3873    }
3874}
3875
3876fn trigger_matches_event(
3877    trigger: &StoredTrigger,
3878    event: &WriteEvent,
3879    cat: &Catalog,
3880) -> Result<bool> {
3881    if trigger.event != event.kind {
3882        return Ok(false);
3883    }
3884    let TriggerTarget::Table(target) = &trigger.target else {
3885        return Ok(false);
3886    };
3887    if target != &event.table {
3888        return Ok(false);
3889    }
3890    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
3891        let schema = &cat
3892            .live(target)
3893            .ok_or_else(|| {
3894                MongrelError::InvalidArgument(format!(
3895                    "trigger {:?} references unknown table {target:?}",
3896                    trigger.name
3897                ))
3898            })?
3899            .schema;
3900        let mut watched = Vec::with_capacity(trigger.update_of.len());
3901        for name in &trigger.update_of {
3902            let col = schema.column(name).ok_or_else(|| {
3903                MongrelError::InvalidArgument(format!(
3904                    "trigger {:?} references unknown UPDATE OF column {name:?}",
3905                    trigger.name
3906                ))
3907            })?;
3908            watched.push(col.id);
3909        }
3910        if !event
3911            .changed_columns
3912            .iter()
3913            .any(|column_id| watched.contains(column_id))
3914        {
3915            return Ok(false);
3916        }
3917    }
3918    Ok(true)
3919}
3920
3921fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
3922    let mut ids = std::collections::BTreeSet::new();
3923    if let Some(old) = old {
3924        ids.extend(old.columns.keys().copied());
3925    }
3926    if let Some(new) = new {
3927        ids.extend(new.columns.keys().copied());
3928    }
3929    ids.into_iter()
3930        .filter(|id| {
3931            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
3932        })
3933        .collect()
3934}
3935
3936fn eval_trigger_cells(
3937    cells: &[crate::trigger::TriggerCell],
3938    event: &WriteEvent,
3939) -> Result<Vec<(u16, Value)>> {
3940    cells
3941        .iter()
3942        .map(|cell| Ok((cell.column_id, eval_trigger_value(&cell.value, event)?)))
3943        .collect()
3944}
3945
3946fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
3947    match expr {
3948        TriggerExpr::Value(value) => match eval_trigger_value(value, event)? {
3949            Value::Bool(value) => Ok(value),
3950            Value::Null => Ok(false),
3951            other => Err(MongrelError::InvalidArgument(format!(
3952                "trigger WHEN value must be boolean, got {other:?}"
3953            ))),
3954        },
3955        TriggerExpr::Eq { left, right } => Ok(values_equal(
3956            &eval_trigger_value(left, event)?,
3957            &eval_trigger_value(right, event)?,
3958        )),
3959        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
3960            &eval_trigger_value(left, event)?,
3961            &eval_trigger_value(right, event)?,
3962        )),
3963        TriggerExpr::IsNull(value) => Ok(matches!(eval_trigger_value(value, event)?, Value::Null)),
3964        TriggerExpr::IsNotNull(value) => {
3965            Ok(!matches!(eval_trigger_value(value, event)?, Value::Null))
3966        }
3967    }
3968}
3969
3970fn eval_trigger_value(value: &TriggerValue, event: &WriteEvent) -> Result<Value> {
3971    match value {
3972        TriggerValue::Literal(value) => Ok(value.clone()),
3973        TriggerValue::NewColumn(column_id) => event
3974            .new
3975            .as_ref()
3976            .and_then(|row| row.columns.get(column_id))
3977            .cloned()
3978            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
3979        TriggerValue::OldColumn(column_id) => event
3980            .old
3981            .as_ref()
3982            .and_then(|row| row.columns.get(column_id))
3983            .cloned()
3984            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
3985    }
3986}
3987
3988fn values_equal(left: &Value, right: &Value) -> bool {
3989    match (left, right) {
3990        (Value::Null, Value::Null) => true,
3991        (Value::Bool(a), Value::Bool(b)) => a == b,
3992        (Value::Int64(a), Value::Int64(b)) => a == b,
3993        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
3994        (Value::Bytes(a), Value::Bytes(b)) => a == b,
3995        (Value::Embedding(a), Value::Embedding(b)) => {
3996            a.len() == b.len()
3997                && a.iter()
3998                    .zip(b.iter())
3999                    .all(|(a, b)| a.to_bits() == b.to_bits())
4000        }
4001        _ => false,
4002    }
4003}
4004
4005fn trigger_message(value: Value) -> String {
4006    match value {
4007        Value::Null => "NULL".into(),
4008        Value::Bool(value) => value.to_string(),
4009        Value::Int64(value) => value.to_string(),
4010        Value::Float64(value) => value.to_string(),
4011        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
4012        Value::Embedding(value) => format!("{value:?}"),
4013    }
4014}
4015
4016fn validate_trigger_step(
4017    step: &TriggerStep,
4018    cat: &Catalog,
4019    target_schema: &Schema,
4020    event: TriggerEvent,
4021) -> Result<()> {
4022    match step {
4023        TriggerStep::SetNew { cells } => {
4024            if event == TriggerEvent::Delete {
4025                return Err(MongrelError::InvalidArgument(
4026                    "SetNew trigger step is not valid for DELETE triggers".into(),
4027                ));
4028            }
4029            for cell in cells {
4030                validate_column_id(cell.column_id, target_schema)?;
4031                validate_trigger_value(&cell.value, target_schema, event)?;
4032            }
4033        }
4034        TriggerStep::Insert { table, cells } => {
4035            let schema = trigger_write_schema(cat, table, "insert")?;
4036            for cell in cells {
4037                validate_column_id(cell.column_id, schema)?;
4038                validate_trigger_value(&cell.value, target_schema, event)?;
4039            }
4040        }
4041        TriggerStep::UpdateByPk { table, pk, cells } => {
4042            let schema = trigger_write_schema(cat, table, "update")?;
4043            if schema.primary_key().is_none() {
4044                return Err(MongrelError::InvalidArgument(format!(
4045                    "trigger update_by_pk references table {table:?} without a primary key"
4046                )));
4047            }
4048            validate_trigger_value(pk, target_schema, event)?;
4049            for cell in cells {
4050                validate_column_id(cell.column_id, schema)?;
4051                validate_trigger_value(&cell.value, target_schema, event)?;
4052            }
4053        }
4054        TriggerStep::DeleteByPk { table, pk } => {
4055            let schema = trigger_write_schema(cat, table, "delete")?;
4056            if schema.primary_key().is_none() {
4057                return Err(MongrelError::InvalidArgument(format!(
4058                    "trigger delete_by_pk references table {table:?} without a primary key"
4059                )));
4060            }
4061            validate_trigger_value(pk, target_schema, event)?;
4062        }
4063        TriggerStep::Select {
4064            table, conditions, ..
4065        } => {
4066            let schema = trigger_read_schema(cat, table)?;
4067            for condition in conditions {
4068                validate_trigger_condition(condition, schema, target_schema, event)?;
4069            }
4070        }
4071        TriggerStep::Raise { message, .. } => {
4072            validate_trigger_value(message, target_schema, event)?
4073        }
4074    }
4075    Ok(())
4076}
4077
4078fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
4079    if let Some(entry) = cat.live(table) {
4080        return Ok(&entry.schema);
4081    }
4082    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
4083        let allowed = match op {
4084            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
4085            "update" | "delete" => entry.capabilities.writable,
4086            _ => false,
4087        };
4088        if !allowed {
4089            return Err(MongrelError::InvalidArgument(format!(
4090                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
4091                entry.module
4092            )));
4093        }
4094        if !entry.capabilities.transaction_safe {
4095            return Err(MongrelError::InvalidArgument(format!(
4096                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
4097                entry.module
4098            )));
4099        }
4100        return Ok(&entry.declared_schema);
4101    }
4102    Err(MongrelError::InvalidArgument(format!(
4103        "trigger references unknown table {table:?}"
4104    )))
4105}
4106
4107fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
4108    if let Some(entry) = cat.live(table) {
4109        return Ok(&entry.schema);
4110    }
4111    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
4112        if entry.capabilities.trigger_safe {
4113            return Ok(&entry.declared_schema);
4114        }
4115        return Err(MongrelError::InvalidArgument(format!(
4116            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
4117            entry.module
4118        )));
4119    }
4120    Err(MongrelError::InvalidArgument(format!(
4121        "trigger references unknown table {table:?}"
4122    )))
4123}
4124
4125fn validate_trigger_condition(
4126    condition: &TriggerCondition,
4127    schema: &Schema,
4128    target_schema: &Schema,
4129    event: TriggerEvent,
4130) -> Result<()> {
4131    match condition {
4132        TriggerCondition::Pk { value } => {
4133            if schema.primary_key().is_none() {
4134                return Err(MongrelError::InvalidArgument(
4135                    "trigger condition Pk references a table without a primary key".into(),
4136                ));
4137            }
4138            validate_trigger_value(value, target_schema, event)
4139        }
4140        TriggerCondition::Eq { column_id, value } => {
4141            validate_column_id(*column_id, schema)?;
4142            validate_trigger_value(value, target_schema, event)
4143        }
4144        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
4145            validate_column_id(*column_id, schema)
4146        }
4147    }
4148}
4149
4150fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
4151    match expr {
4152        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
4153            validate_trigger_value(value, schema, event)
4154        }
4155        TriggerExpr::Eq { left, right } | TriggerExpr::NotEq { left, right } => {
4156            validate_trigger_value(left, schema, event)?;
4157            validate_trigger_value(right, schema, event)
4158        }
4159    }
4160}
4161
4162fn validate_trigger_value(
4163    value: &TriggerValue,
4164    schema: &Schema,
4165    event: TriggerEvent,
4166) -> Result<()> {
4167    match value {
4168        TriggerValue::Literal(_) => Ok(()),
4169        TriggerValue::NewColumn(id) => {
4170            if event == TriggerEvent::Delete {
4171                return Err(MongrelError::InvalidArgument(
4172                    "DELETE triggers cannot reference NEW".into(),
4173                ));
4174            }
4175            validate_column_id(*id, schema)
4176        }
4177        TriggerValue::OldColumn(id) => {
4178            if event == TriggerEvent::Insert {
4179                return Err(MongrelError::InvalidArgument(
4180                    "INSERT triggers cannot reference OLD".into(),
4181                ));
4182            }
4183            validate_column_id(*id, schema)
4184        }
4185    }
4186}
4187
4188/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
4189/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
4190/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
4191/// catalog. This pass closes that window by reconstructing missing entries
4192/// (and marking committed drops) before tables are mounted.
4193fn recover_ddl_from_wal(
4194    root: &Path,
4195    cat: &mut Catalog,
4196    meta_dek: Option<&[u8; META_DEK_LEN]>,
4197    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
4198) -> Result<()> {
4199    use crate::wal::{DdlOp, Op, SharedWal};
4200
4201    let records = match SharedWal::replay_with_dek(root, wal_dek) {
4202        Ok(r) => r,
4203        Err(_) => return Ok(()),
4204    };
4205
4206    let mut committed: HashMap<u64, u64> = HashMap::new();
4207    for r in &records {
4208        if let Op::TxnCommit { epoch: ce, .. } = r.op {
4209            committed.insert(r.txn_id, ce);
4210        }
4211    }
4212
4213    let mut changed = false;
4214    for r in records {
4215        let Some(&ce) = committed.get(&r.txn_id) else {
4216            continue;
4217        };
4218        match r.op {
4219            Op::Ddl(DdlOp::CreateTable {
4220                table_id,
4221                ref name,
4222                ref schema_json,
4223            }) => {
4224                if cat.tables.iter().any(|t| t.table_id == table_id) {
4225                    continue;
4226                }
4227                let schema = DdlOp::decode_schema(schema_json)?;
4228                let tdir = root.join(TABLES_DIR).join(table_id.to_string());
4229                if !tdir.exists() {
4230                    std::fs::create_dir_all(tdir.join(crate::engine::WAL_DIR))?;
4231                    std::fs::create_dir_all(tdir.join(crate::engine::RUNS_DIR))?;
4232                    crate::engine::write_schema(&tdir, &schema)?;
4233                    // The DB-wide meta DEK is also the per-table manifest meta
4234                    // DEK (both derive from the KEK via `derive_meta_key`), so a
4235                    // reconstructed manifest must be sealed with it — otherwise
4236                    // the follow-up `Table::open_in` cannot authenticate it on an
4237                    // encrypted DB and the table becomes permanently unopenable.
4238                    let mut m = crate::manifest::Manifest::new(table_id, schema.schema_id);
4239                    crate::manifest::write_atomic(&tdir, &mut m, meta_dek)?;
4240                }
4241                cat.tables.push(CatalogEntry {
4242                    table_id,
4243                    name: name.clone(),
4244                    schema,
4245                    state: TableState::Live,
4246                    created_epoch: ce,
4247                });
4248                cat.next_table_id = cat.next_table_id.max(table_id + 1);
4249                changed = true;
4250            }
4251            Op::Ddl(DdlOp::DropTable { table_id }) => {
4252                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
4253                    if matches!(entry.state, TableState::Live) {
4254                        entry.state = TableState::Dropped { at_epoch: ce };
4255                        changed = true;
4256                    }
4257                }
4258            }
4259            Op::Ddl(DdlOp::RenameTable {
4260                table_id,
4261                ref new_name,
4262            }) => {
4263                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
4264                    if entry.name != *new_name {
4265                        entry.name = new_name.clone();
4266                        changed = true;
4267                    }
4268                }
4269                // If the entry is absent, its CreateTable was already
4270                // checkpointed carrying the post-rename name, so there is
4271                // nothing to apply — a no-op, not an error.
4272            }
4273            Op::Ddl(DdlOp::AlterTable {
4274                table_id,
4275                ref column_json,
4276            }) => {
4277                let column = DdlOp::decode_column(column_json)?;
4278                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
4279                    if apply_recovered_column_def(&mut entry.schema, column) {
4280                        let tdir = root.join(TABLES_DIR).join(table_id.to_string());
4281                        if tdir.exists() {
4282                            crate::engine::write_schema(&tdir, &entry.schema)?;
4283                        }
4284                        changed = true;
4285                    }
4286                }
4287            }
4288            _ => {}
4289        }
4290    }
4291
4292    if changed {
4293        catalog::write_atomic(root, cat, meta_dek)?;
4294    }
4295    Ok(())
4296}
4297
4298fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> bool {
4299    match schema.columns.iter_mut().find(|c| c.id == column.id) {
4300        Some(existing) if *existing == column => false,
4301        Some(existing) => {
4302            *existing = column;
4303            schema.schema_id = schema.schema_id.saturating_add(1);
4304            true
4305        }
4306        None => {
4307            schema.columns.push(column);
4308            schema.schema_id = schema.schema_id.saturating_add(1);
4309            true
4310        }
4311    }
4312}
4313
4314/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
4315/// #14). These dirs hold pending uniform-epoch runs from large transactions
4316/// that were aborted or crashed before commit. On open, all such dirs are safe
4317/// to remove — committed txns moved their runs to `_runs/` at publish time.
4318fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
4319    for entry in &cat.tables {
4320        let txn_dir = root
4321            .join(TABLES_DIR)
4322            .join(entry.table_id.to_string())
4323            .join("_txn");
4324        if txn_dir.exists() {
4325            let _ = std::fs::remove_dir_all(&txn_dir);
4326        }
4327    }
4328}