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