Skip to main content

mongreldb_core/
database.rs

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