Skip to main content

mongreldb_core/
database.rs

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