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