Skip to main content

mongreldb_core/
database.rs

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