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::procedure::{
14    ProcedureCallOutput, ProcedureCallResult, ProcedureCallRow, ProcedureCondition, ProcedureEntry,
15    ProcedureStep, ProcedureValue, StoredProcedure,
16};
17use crate::retention::{OwnedSnapshotGuard, SnapshotGuard, SnapshotRegistry};
18use crate::schema::{AlterColumn, ColumnDef, Schema};
19use parking_lot::{Mutex, RwLock};
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24pub const TABLES_DIR: &str = "tables";
25pub const META_DIR: &str = "_meta";
26pub const KEYS_FILENAME: &str = "keys";
27
28/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
29/// than any table. `u64::MAX` is never allocated to a real table (the catalog
30/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
31pub const WAL_TABLE_ID: u64 = u64::MAX;
32
33/// A pending uniform-epoch run written during a large transaction (spec §8.5).
34struct SpilledRun {
35    table_id: u64,
36    run_id: u128,
37    pending_path: PathBuf,
38    rows: Vec<crate::memtable::Row>,
39    row_count: u64,
40    min_rid: u64,
41    max_rid: u64,
42}
43
44/// An integrity issue found by [`Database::check`] (spec §16).
45#[derive(Debug, Clone)]
46pub struct CheckIssue {
47    pub table_id: u64,
48    pub table_name: String,
49    pub severity: String,
50    pub description: String,
51}
52
53/// A handle to a live table inside a [`Database`]. Writes take the inner lock
54/// (P1); P3.3 replaces this with lock-free `ArcSwap` reads + a publish lock for
55/// writes.
56pub type TableHandle = Arc<Mutex<Table>>;
57
58/// A multi-table database: one catalog, one epoch clock, shared caches, a
59/// shared WAL, and a live map of name → `Arc<Table>`.
60pub struct Database {
61    root: PathBuf,
62    catalog: RwLock<Catalog>,
63    epoch: Arc<EpochAuthority>,
64    snapshots: Arc<SnapshotRegistry>,
65    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
66    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
67    commit_lock: Arc<Mutex<()>>,
68    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
69    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
70    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
71    /// writes also land in this one WAL (B1 — one WAL per database).
72    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
73    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
74    /// in P2.7; here it just needs to be unique within an open. Shared with
75    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
76    next_txn_id: Arc<Mutex<u64>>,
77    tables: RwLock<HashMap<u64, TableHandle>>,
78    kek: Option<Arc<crate::encryption::Kek>>,
79    /// Serializes DDL (create/drop table); data commits serialize through
80    /// `commit_lock` shared via `SharedCtx`.
81    ddl_lock: Mutex<()>,
82    meta_dek: Option<[u8; META_DEK_LEN]>,
83    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
84    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
85    spill_threshold: std::sync::atomic::AtomicU64,
86    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
87    /// detection (spec §9.2).
88    conflicts: crate::txn::ConflictIndex,
89    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
90    /// pruning (spec §9.2, review fix #12).
91    active_txns: crate::txn::ActiveTxns,
92    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
93    /// Shared with mounted tables so a single-table commit also honors poison.
94    poisoned: Arc<std::sync::atomic::AtomicBool>,
95    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
96    /// but defers the fsync to one leader here, so concurrent commits share a
97    /// single fsync (spec §9.3). Shared with mounted tables.
98    group: Arc<crate::txn::GroupCommit>,
99    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
100    /// live spill's pending dir (review fix #14, spec §6.4).
101    active_spills: Arc<crate::retention::ActiveSpills>,
102    /// Test-only barrier invoked after a transaction writes its spill runs but
103    /// before the sequencer/publish, so tests can race `gc()` against an
104    /// in-flight spill. `None` in production.
105    #[doc(hidden)]
106    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
107}
108
109impl Database {
110    /// Create a fresh plaintext database at `root`.
111    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
112        Self::create_inner(root, None)
113    }
114
115    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
116    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
117    #[cfg(feature = "encryption")]
118    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
119        let root = root.as_ref();
120        std::fs::create_dir_all(root)?;
121        std::fs::create_dir_all(root.join(META_DIR))?;
122        let salt = crate::encryption::random_salt();
123        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
124        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
125        Self::create_inner(root, Some(kek))
126    }
127
128    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
129    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
130    #[cfg(feature = "encryption")]
131    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
132        let root = root.as_ref();
133        std::fs::create_dir_all(root)?;
134        std::fs::create_dir_all(root.join(META_DIR))?;
135        let salt = crate::encryption::random_salt();
136        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
137        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
138        Self::create_inner(root, Some(kek))
139    }
140
141    fn create_inner(
142        root: impl AsRef<Path>,
143        kek: Option<Arc<crate::encryption::Kek>>,
144    ) -> Result<Self> {
145        let root = root.as_ref().to_path_buf();
146        std::fs::create_dir_all(&root)?;
147        std::fs::create_dir_all(root.join(TABLES_DIR))?;
148        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
149        let cat = Catalog::empty();
150        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
151        Self::finish_open(root, cat, kek, meta_dek, false)
152    }
153
154    /// Open an existing plaintext database.
155    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
156        Self::open_inner(root, None, None)
157    }
158
159    /// Open an existing encrypted database with a passphrase.
160    #[cfg(feature = "encryption")]
161    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
162        let root = root.as_ref();
163        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
164            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
165        let mut salt = [0u8; crate::encryption::SALT_LEN];
166        salt.copy_from_slice(&salt_bytes);
167        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
168        Self::open_inner(root, Some(kek), None)
169    }
170
171    /// Open an existing encrypted database using a raw high-entropy key.
172    #[cfg(feature = "encryption")]
173    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
174        let root = root.as_ref();
175        let salt_path = root.join(META_DIR).join(KEYS_FILENAME);
176        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
177            MongrelError::NotFound(format!(
178                "encryption salt file {:?}: {e} (database not encrypted, or corrupted)",
179                salt_path
180            ))
181        })?;
182        if salt_bytes.len() != crate::encryption::SALT_LEN {
183            return Err(MongrelError::InvalidArgument(format!(
184                "salt file is {} bytes, expected {}",
185                salt_bytes.len(),
186                crate::encryption::SALT_LEN
187            )));
188        }
189        let mut salt = [0u8; crate::encryption::SALT_LEN];
190        salt.copy_from_slice(&salt_bytes);
191        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
192        Self::open_inner(root, Some(kek), None)
193    }
194
195    fn open_inner(
196        root: impl AsRef<Path>,
197        kek: Option<Arc<crate::encryption::Kek>>,
198        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
199    ) -> Result<Self> {
200        let root = root.as_ref().to_path_buf();
201        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
202        let cat = catalog::read(&root, meta_dek.as_ref())?
203            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
204        Self::finish_open(root, cat, kek, meta_dek, true)
205    }
206
207    fn finish_open(
208        root: PathBuf,
209        cat: Catalog,
210        kek: Option<Arc<crate::encryption::Kek>>,
211        meta_dek: Option<[u8; META_DEK_LEN]>,
212        existing: bool,
213    ) -> Result<Self> {
214        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
215        let snapshots = Arc::new(SnapshotRegistry::new());
216        let page_cache = Arc::new(crate::cache::Sharded::new(
217            crate::cache::CACHE_SHARDS,
218            || {
219                crate::cache::PageCache::new(
220                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
221                )
222            },
223        ));
224        let decoded_cache = Arc::new(crate::cache::Sharded::new(
225            crate::cache::CACHE_SHARDS,
226            || {
227                crate::cache::DecodedPageCache::new(
228                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
229                )
230            },
231        ));
232        let commit_lock = Arc::new(Mutex::new(()));
233        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
234        let shared_wal = Arc::new(Mutex::new(if existing {
235            crate::wal::SharedWal::open(&root, Epoch(cat.db_epoch), wal_dek.clone())?
236        } else {
237            crate::wal::SharedWal::create_with_dek(&root, Epoch(cat.db_epoch), wal_dek.clone())?
238        }));
239        // Shared write-path state handed to every mounted table so single-table
240        // `put`/`commit` writes route through the one shared WAL, the one group-
241        // commit coordinator, and the one poison flag (B1).
242        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
243        let group = Arc::new(crate::txn::GroupCommit::new(
244            shared_wal.lock().durable_seq(),
245        ));
246        // Final base value is set after the open-generation bump below; tables
247        // only draw ids once the user issues a write (post-open), so the
248        // placeholder is never observed.
249        let txn_ids = Arc::new(Mutex::new(1u64));
250
251        // Recover DDL from the shared WAL BEFORE opening tables (spec §15,
252        // review fix #16). A crash between WAL fsync and the catalog
253        // checkpoint leaves committed DDL durable in the WAL but absent from
254        // the on-disk catalog; replay it here so the table-mounting loop and
255        // data-record recovery see a correct catalog.
256        let mut cat = cat;
257        if existing {
258            recover_ddl_from_wal(&root, &mut cat, meta_dek.as_ref(), wal_dek.as_ref())?;
259        }
260
261        // Open every live table against the shared context. Mounted tables have
262        // no private WAL (B1) — `open_in` just loads the manifest/runs and
263        // advances the shared epoch authority to its manifest epoch, so the
264        // final shared watermark is the max across all tables. All of a mounted
265        // table's committed records are replayed below from the shared WAL.
266        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
267        for entry in &cat.tables {
268            if !matches!(entry.state, TableState::Live) {
269                continue;
270            }
271            let tdir = root.join(TABLES_DIR).join(entry.table_id.to_string());
272            let ctx = SharedCtx {
273                epoch: Arc::clone(&epoch),
274                page_cache: Arc::clone(&page_cache),
275                decoded_cache: Arc::clone(&decoded_cache),
276                snapshots: Arc::clone(&snapshots),
277                kek: kek.clone(),
278                commit_lock: Arc::clone(&commit_lock),
279                shared: Some(crate::engine::SharedWalCtx {
280                    wal: Arc::clone(&shared_wal),
281                    group: Arc::clone(&group),
282                    poisoned: Arc::clone(&poisoned),
283                    txn_ids: Arc::clone(&txn_ids),
284                }),
285            };
286            let t = Table::open_in(&tdir, ctx)?;
287            tables.insert(entry.table_id, Arc::new(Mutex::new(t)));
288        }
289
290        // Recover transaction writes from the shared WAL (spec §15). This is the
291        // single durability source for mounted tables: it applies every committed
292        // record — both single-table `Table::commit` writes and cross-table
293        // transactions — gated by each table's `flushed_epoch` (records already
294        // durable in a run are not re-applied).
295        if existing {
296            recover_shared_wal(&root, &tables, &epoch, wal_dek.as_ref())?;
297            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
298            // large transactions (spec §8.5, review fix #14).
299            sweep_pending_txn_dirs(&root, &cat);
300        }
301
302        // Bump `open_generation` on every open and scope transaction ids by it
303        // (`txn_id = (generation << 32) | counter`), so ids never alias across
304        // reopens (review fix #11). Persist the bumped generation to the catalog.
305        if existing {
306            cat.open_generation = cat.open_generation.wrapping_add(1);
307            catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
308        }
309        let next_txn_id = (cat.open_generation << 32) | 1;
310        // Seed the shared txn-id allocator now that the generation is final.
311        *txn_ids.lock() = next_txn_id;
312
313        Ok(Self {
314            root,
315            catalog: RwLock::new(cat),
316            epoch,
317            snapshots,
318            page_cache,
319            decoded_cache,
320            commit_lock,
321            shared_wal,
322            next_txn_id: txn_ids,
323            tables: RwLock::new(tables),
324            kek,
325            ddl_lock: Mutex::new(()),
326            meta_dek,
327            conflicts: crate::txn::ConflictIndex::new(),
328            active_txns: crate::txn::ActiveTxns::new(),
329            poisoned,
330            group,
331            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
332            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
333            spill_hook: Mutex::new(None),
334        })
335    }
336
337    /// The current reader-visible epoch.
338    pub fn visible_epoch(&self) -> Epoch {
339        self.epoch.visible()
340    }
341
342    /// Clone the in-memory catalog (for diagnostics / tests).
343    pub fn catalog_snapshot(&self) -> Catalog {
344        self.catalog.read().clone()
345    }
346
347    /// The filesystem root this database was opened/created at.
348    pub fn root(&self) -> &Path {
349        &self.root
350    }
351
352    /// Resolve a table name → id (live tables only). pub(crate) so the
353    /// transaction layer can stage by name.
354    pub fn table_id(&self, name: &str) -> Result<u64> {
355        let cat = self.catalog.read();
356        cat.live(name)
357            .map(|e| e.table_id)
358            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
359    }
360
361    pub fn procedures(&self) -> Vec<StoredProcedure> {
362        self.catalog
363            .read()
364            .procedures
365            .iter()
366            .map(|p| p.procedure.clone())
367            .collect()
368    }
369
370    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
371        self.catalog
372            .read()
373            .procedures
374            .iter()
375            .find(|p| p.procedure.name == name)
376            .map(|p| p.procedure.clone())
377    }
378
379    pub fn create_procedure(&self, mut procedure: StoredProcedure) -> Result<StoredProcedure> {
380        let _g = self.ddl_lock.lock();
381        procedure.validate()?;
382        self.validate_procedure_references(&procedure)?;
383        {
384            let cat = self.catalog.read();
385            if cat
386                .procedures
387                .iter()
388                .any(|p| p.procedure.name == procedure.name)
389            {
390                return Err(MongrelError::InvalidArgument(format!(
391                    "procedure {:?} already exists",
392                    procedure.name
393                )));
394            }
395        }
396        let commit_lock = Arc::clone(&self.commit_lock);
397        let _c = commit_lock.lock();
398        let epoch = self.epoch.bump_assigned();
399        procedure.created_epoch = epoch.0;
400        procedure.updated_epoch = epoch.0;
401        {
402            let mut cat = self.catalog.write();
403            cat.procedures.push(ProcedureEntry::from(procedure.clone()));
404            cat.db_epoch = epoch.0;
405        }
406        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
407        self.advance_visible(epoch);
408        Ok(procedure)
409    }
410
411    pub fn create_or_replace_procedure(
412        &self,
413        procedure: StoredProcedure,
414    ) -> Result<StoredProcedure> {
415        let _g = self.ddl_lock.lock();
416        procedure.validate()?;
417        self.validate_procedure_references(&procedure)?;
418        let commit_lock = Arc::clone(&self.commit_lock);
419        let _c = commit_lock.lock();
420        let epoch = self.epoch.bump_assigned();
421        let replaced = {
422            let mut cat = self.catalog.write();
423            let next = match cat
424                .procedures
425                .iter()
426                .position(|p| p.procedure.name == procedure.name)
427            {
428                Some(idx) => {
429                    let next = cat.procedures[idx]
430                        .procedure
431                        .replaced(procedure.clone(), epoch.0)?;
432                    cat.procedures[idx] = ProcedureEntry::from(next.clone());
433                    next
434                }
435                None => {
436                    let mut next = procedure;
437                    next.created_epoch = epoch.0;
438                    next.updated_epoch = epoch.0;
439                    cat.procedures.push(ProcedureEntry::from(next.clone()));
440                    next
441                }
442            };
443            cat.db_epoch = epoch.0;
444            next
445        };
446        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
447        self.advance_visible(epoch);
448        Ok(replaced)
449    }
450
451    pub fn drop_procedure(&self, name: &str) -> Result<()> {
452        let _g = self.ddl_lock.lock();
453        let commit_lock = Arc::clone(&self.commit_lock);
454        let _c = commit_lock.lock();
455        let epoch = self.epoch.bump_assigned();
456        {
457            let mut cat = self.catalog.write();
458            let before = cat.procedures.len();
459            cat.procedures.retain(|p| p.procedure.name != name);
460            if cat.procedures.len() == before {
461                return Err(MongrelError::NotFound(format!(
462                    "procedure {name:?} not found"
463                )));
464            }
465            cat.db_epoch = epoch.0;
466        }
467        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
468        self.advance_visible(epoch);
469        Ok(())
470    }
471
472    pub fn call_procedure(
473        &self,
474        name: &str,
475        args: HashMap<String, crate::Value>,
476    ) -> Result<ProcedureCallResult> {
477        let procedure = self
478            .procedure(name)
479            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
480        let args = bind_procedure_args(&procedure, args)?;
481        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
482        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
483        if has_writes {
484            let mut tx = self.begin();
485            let run = (|| {
486                for step in &procedure.body.steps {
487                    let output =
488                        self.execute_procedure_step(step, &args, &outputs, Some(&mut tx))?;
489                    outputs.insert(step.id().to_string(), output);
490                }
491                eval_return_output(&procedure.body.return_value, &args, &outputs)
492            })();
493            match run {
494                Ok(output) => {
495                    let epoch = tx.commit()?.0;
496                    Ok(ProcedureCallResult {
497                        epoch: Some(epoch),
498                        output,
499                    })
500                }
501                Err(e) => {
502                    tx.rollback();
503                    Err(e)
504                }
505            }
506        } else {
507            for step in &procedure.body.steps {
508                let output = self.execute_procedure_step(step, &args, &outputs, None)?;
509                outputs.insert(step.id().to_string(), output);
510            }
511            Ok(ProcedureCallResult {
512                epoch: None,
513                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
514            })
515        }
516    }
517
518    fn execute_procedure_step(
519        &self,
520        step: &ProcedureStep,
521        args: &HashMap<String, crate::Value>,
522        outputs: &HashMap<String, ProcedureCallOutput>,
523        tx: Option<&mut crate::txn::Transaction<'_>>,
524    ) -> Result<ProcedureCallOutput> {
525        match step {
526            ProcedureStep::NativeQuery {
527                table,
528                conditions,
529                projection,
530                limit,
531                ..
532            } => {
533                let mut q = crate::Query::new();
534                for condition in conditions {
535                    q = q.and(eval_condition(condition, args, outputs)?);
536                }
537                let handle = self.table(table)?;
538                let mut rows = handle.lock().query(&q)?;
539                if let Some(limit) = limit {
540                    rows.truncate(*limit);
541                }
542                let projection = projection.as_ref();
543                Ok(ProcedureCallOutput::Rows(
544                    rows.into_iter()
545                        .map(|row| ProcedureCallRow {
546                            row_id: Some(row.row_id),
547                            columns: match projection {
548                                Some(ids) => row
549                                    .columns
550                                    .into_iter()
551                                    .filter(|(id, _)| ids.contains(id))
552                                    .collect(),
553                                None => row.columns,
554                            },
555                        })
556                        .collect(),
557                ))
558            }
559            ProcedureStep::Put {
560                table,
561                cells,
562                returning,
563                ..
564            } => {
565                let tx = tx.ok_or_else(|| {
566                    MongrelError::InvalidArgument(
567                        "write procedure step requires a transaction".into(),
568                    )
569                })?;
570                let cells = eval_cells(cells, args, outputs)?;
571                if *returning {
572                    let out = tx.put_returning(table, cells)?;
573                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
574                        row_id: None,
575                        columns: out.row.columns.into_iter().collect(),
576                    }))
577                } else {
578                    tx.put(table, cells)?;
579                    Ok(ProcedureCallOutput::Null)
580                }
581            }
582            ProcedureStep::Upsert {
583                table,
584                cells,
585                update_cells,
586                returning,
587                ..
588            } => {
589                let tx = tx.ok_or_else(|| {
590                    MongrelError::InvalidArgument(
591                        "write procedure step requires a transaction".into(),
592                    )
593                })?;
594                let cells = eval_cells(cells, args, outputs)?;
595                let action = match update_cells {
596                    Some(update_cells) => {
597                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
598                    }
599                    None => crate::UpsertAction::DoNothing,
600                };
601                let out = tx.upsert(table, cells, action)?;
602                if *returning {
603                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
604                        row_id: None,
605                        columns: out.row.columns.into_iter().collect(),
606                    }))
607                } else {
608                    Ok(ProcedureCallOutput::Null)
609                }
610            }
611            ProcedureStep::DeleteByPk { table, pk, .. } => {
612                let tx = tx.ok_or_else(|| {
613                    MongrelError::InvalidArgument(
614                        "write procedure step requires a transaction".into(),
615                    )
616                })?;
617                let pk = eval_value(pk, args, outputs)?;
618                let handle = self.table(table)?;
619                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
620                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
621                })?;
622                tx.delete(table, row_id)?;
623                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
624            }
625            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
626                "DeleteRows procedure step is not supported by the core executor yet".into(),
627            )),
628            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
629                "SqlQuery procedure step must be executed by mongreldb-query".into(),
630            )),
631        }
632    }
633
634    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
635        let cat = self.catalog.read();
636        for step in &procedure.body.steps {
637            let Some(table_name) = step.table() else {
638                continue;
639            };
640            let schema = &cat
641                .live(table_name)
642                .ok_or_else(|| {
643                    MongrelError::InvalidArgument(format!(
644                        "procedure {:?} references unknown table {table_name:?}",
645                        procedure.name
646                    ))
647                })?
648                .schema;
649            match step {
650                ProcedureStep::NativeQuery {
651                    conditions,
652                    projection,
653                    ..
654                } => {
655                    for condition in conditions {
656                        validate_condition_columns(condition, schema)?;
657                    }
658                    if let Some(projection) = projection {
659                        for id in projection {
660                            validate_column_id(*id, schema)?;
661                        }
662                    }
663                }
664                ProcedureStep::Put { cells, .. } => {
665                    for cell in cells {
666                        validate_column_id(cell.column_id, schema)?;
667                    }
668                }
669                ProcedureStep::Upsert {
670                    cells,
671                    update_cells,
672                    ..
673                } => {
674                    for cell in cells {
675                        validate_column_id(cell.column_id, schema)?;
676                    }
677                    if let Some(update_cells) = update_cells {
678                        for cell in update_cells {
679                            validate_column_id(cell.column_id, schema)?;
680                        }
681                    }
682                }
683                ProcedureStep::DeleteByPk { .. } => {
684                    if schema.primary_key().is_none() {
685                        return Err(MongrelError::InvalidArgument(format!(
686                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
687                            procedure.name
688                        )));
689                    }
690                }
691                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
692            }
693        }
694        Ok(())
695    }
696
697    /// Begin a new transaction reading at the current visible epoch.
698    pub fn begin(&self) -> crate::txn::Transaction<'_> {
699        let txn_id = self.alloc_txn_id();
700        let read = Snapshot::at(self.epoch.visible());
701        crate::txn::Transaction::new(self, txn_id, read)
702    }
703
704    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
705    pub fn transaction<T>(
706        &self,
707        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
708    ) -> Result<T> {
709        let mut tx = self.begin();
710        match f(&mut tx) {
711            Ok(out) => {
712                tx.commit()?;
713                Ok(out)
714            }
715            Err(e) => {
716                tx.rollback();
717                Err(e)
718            }
719        }
720    }
721
722    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
723    /// `Transaction::new` so registration happens **before** any read.
724    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
725        self.active_txns.register(epoch)
726    }
727
728    /// Authoritatively validate every declared constraint on the staged write
729    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
730    /// SET NULL actions into explicit child ops. Called from
731    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
732    /// violation as an `Err`, aborting the commit atomically. This is the
733    /// server-side authority point: concurrent remote writers that each pass
734    /// their own client-side checks still cannot both commit a violating batch.
735    ///
736    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
737    /// intra-transaction dedup; concurrent-txn races are additionally caught by
738    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
739    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
740    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
741    /// RESTRICT-only (cascade-truncate is unsupported).
742    fn validate_constraints(
743        &self,
744        staging: &mut Vec<(u64, crate::txn::Staged)>,
745        read_epoch: Epoch,
746    ) -> Result<()> {
747        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
748        use crate::memtable::Row;
749        use crate::txn::Staged;
750        use std::collections::HashSet;
751
752        let snapshot = Snapshot::at(read_epoch);
753        let cat = self.catalog.read();
754
755        // Collect live (id, name, constraints-bearing?) for staged tables.
756        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
757            .tables
758            .iter()
759            .filter(|e| matches!(e.state, TableState::Live))
760            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
761            .collect();
762
763        // Fast path: bail if no live table declares any constraints at all.
764        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
765        if !any_constraints {
766            return Ok(());
767        }
768
769        // Lazily-loaded visible rows per table, shared across checks.
770        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
771        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
772            if let Some(r) = rows_cache.get(&table_id) {
773                return Ok(r.clone());
774            }
775            let handle = self.table_by_id(table_id)?;
776            let rows = handle.lock().visible_rows(snapshot)?;
777            rows_cache.insert(table_id, rows.clone());
778            Ok(rows)
779        };
780
781        // ── Phase A: expand ON DELETE CASCADE / SET NULL into explicit child
782        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
783        // enforced as a violation in Phase B. `cascaded` records every delete
784        // we have already expanded so a self-referential CASCADE FK cannot loop.
785        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
786        loop {
787            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
788            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
789                .iter()
790                .filter_map(|(t, op)| match op {
791                    Staged::Delete(rid) => Some((*t, *rid)),
792                    _ => None,
793                })
794                .collect();
795            for (table_id, rid) in deletes {
796                if !cascaded.insert((table_id, rid.0)) {
797                    continue;
798                }
799                let Some(tname) = live
800                    .iter()
801                    .find(|(t, _, _)| *t == table_id)
802                    .map(|(_, n, _)| *n)
803                else {
804                    continue;
805                };
806                let parent_handle = self.table_by_id(table_id)?;
807                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
808                    continue;
809                };
810                for (child_id, _child_name, child_schema) in &live {
811                    for fk in &child_schema.constraints.foreign_keys {
812                        if fk.ref_table != tname {
813                            continue;
814                        }
815                        let Some(parent_key) =
816                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
817                        else {
818                            continue;
819                        };
820                        match fk.on_delete {
821                            FkAction::Restrict => continue,
822                            FkAction::Cascade => {
823                                let child_rows = load_rows(*child_id)?;
824                                for cr in &child_rows {
825                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
826                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
827                                            == Some(parent_key.as_slice())
828                                    {
829                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
830                                    }
831                                }
832                            }
833                            FkAction::SetNull => {
834                                let child_rows = load_rows(*child_id)?;
835                                for cr in &child_rows {
836                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
837                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
838                                            == Some(parent_key.as_slice())
839                                    {
840                                        // Re-emit the child row with the FK
841                                        // columns set to NULL (delete + put).
842                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
843                                            .columns
844                                            .iter()
845                                            .map(|(k, v)| (*k, v.clone()))
846                                            .collect();
847                                        for cid in &fk.columns {
848                                            cells.retain(|(k, _)| k != cid);
849                                            cells.push((*cid, crate::memtable::Value::Null));
850                                        }
851                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
852                                        new_ops.push((*child_id, Staged::Put(cells)));
853                                    }
854                                }
855                            }
856                        }
857                    }
858                }
859            }
860            if new_ops.is_empty() {
861                break;
862            }
863            staging.extend(new_ops);
864        }
865
866        // Rows staged for deletion in THIS transaction (now including cascaded
867        // deletes). Used to exclude the old version of an updated row from
868        // unique-existence scans.
869        let staged_deletes: HashSet<(u64, u64)> = staging
870            .iter()
871            .filter_map(|(t, op)| match op {
872                Staged::Delete(rid) => Some((*t, rid.0)),
873                _ => None,
874            })
875            .collect();
876
877        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
878        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
879
880        // ── Phase B: validate the fully-expanded staging set.
881        for (table_id, op) in staging.iter() {
882            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
883            else {
884                continue;
885            };
886            let cells_map: HashMap<u16, crate::memtable::Value>;
887            match op {
888                Staged::Put(cells) => {
889                    cells_map = cells.iter().cloned().collect();
890
891                    // CHECK constraints.
892                    if !schema.constraints.checks.is_empty() {
893                        validate_checks(&schema.constraints.checks, &cells_map)?;
894                    }
895
896                    // UNIQUE (non-PK) constraints.
897                    for uc in &schema.constraints.uniques {
898                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
899                            continue; // NULL in a constrained column → skip (SQL).
900                        };
901                        let marker = (*table_id, uc.id, key.clone());
902                        if !seen_unique.insert(marker) {
903                            return Err(MongrelError::Conflict(format!(
904                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
905                                uc.name
906                            )));
907                        }
908                        let rows = load_rows(*table_id)?;
909                        for r in &rows {
910                            // Skip rows this same transaction is deleting (the
911                            // old version of an updated/cascade-deleted row).
912                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
913                                continue;
914                            }
915                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
916                                if theirs == key {
917                                    return Err(MongrelError::Conflict(format!(
918                                        "UNIQUE constraint '{}' on table '{tname}' violated",
919                                        uc.name
920                                    )));
921                                }
922                            }
923                        }
924                    }
925
926                    // FK insert-side: parent must exist.
927                    for fk in &schema.constraints.foreign_keys {
928                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
929                            continue; // NULL FK component → not checked (SQL).
930                        };
931                        let Some(parent_id) = cat
932                            .tables
933                            .iter()
934                            .find(|t| t.name == fk.ref_table)
935                            .map(|t| t.table_id)
936                        else {
937                            return Err(MongrelError::InvalidArgument(format!(
938                                "FOREIGN KEY '{}' references unknown table '{}'",
939                                fk.name, fk.ref_table
940                            )));
941                        };
942                        let parent_rows = load_rows(parent_id)?;
943                        let mut found = false;
944                        for r in &parent_rows {
945                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
946                                continue;
947                            }
948                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
949                                if pkey == child_key {
950                                    found = true;
951                                    break;
952                                }
953                            }
954                        }
955                        if !found {
956                            return Err(MongrelError::Conflict(format!(
957                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
958                                fk.name, fk.ref_table
959                            )));
960                        }
961                    }
962                }
963                Staged::Delete(rid) => {
964                    // FK ON DELETE RESTRICT: a child row (whose FK action is
965                    // RESTRICT) referencing this parent blocks the delete.
966                    // CASCADE/SET NULL children were expanded in Phase A.
967                    let parent_handle = self.table_by_id(*table_id)?;
968                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
969                        continue;
970                    };
971                    for (child_id, child_name, child_schema) in &live {
972                        for fk in &child_schema.constraints.foreign_keys {
973                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
974                                continue;
975                            }
976                            let Some(parent_key) =
977                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
978                            else {
979                                continue;
980                            };
981                            let child_rows = load_rows(*child_id)?;
982                            for r in &child_rows {
983                                // A child already being deleted by this txn
984                                // (cascade/inline) is not a restrict violation.
985                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
986                                    continue;
987                                }
988                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
989                                    if ck == parent_key {
990                                        return Err(MongrelError::Conflict(format!(
991                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
992                                            fk.name
993                                        )));
994                                    }
995                                }
996                            }
997                        }
998                    }
999                }
1000                Staged::Truncate => {
1001                    // Truncate is RESTRICT-only: reject if any child references
1002                    // this table (any FK action), since cascade-truncate is
1003                    // unsupported.
1004                    for (child_id, child_name, child_schema) in &live {
1005                        for fk in &child_schema.constraints.foreign_keys {
1006                            if fk.ref_table != tname {
1007                                continue;
1008                            }
1009                            let child_rows = load_rows(*child_id)?;
1010                            if child_rows
1011                                .iter()
1012                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
1013                            {
1014                                return Err(MongrelError::Conflict(format!(
1015                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
1016                                    fk.name
1017                                )));
1018                            }
1019                        }
1020                    }
1021                }
1022            }
1023        }
1024        Ok(())
1025    }
1026
1027    /// Seal a transaction (spec §9.3):
1028    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
1029    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
1030    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
1031    ///    group-sync, record conflict keys.
1032    /// 3. Publish — apply to tables, advance visible in-order.
1033    pub(crate) fn commit_transaction(
1034        &self,
1035        txn_id: u64,
1036        read_epoch: Epoch,
1037        mut staging: Vec<(u64, crate::txn::Staged)>,
1038    ) -> Result<Epoch> {
1039        use crate::memtable::Row;
1040        use crate::txn::{Staged, StagedOp, WriteKey};
1041        use crate::wal::Op;
1042        use std::collections::hash_map::DefaultHasher;
1043        use std::hash::{Hash, Hasher};
1044        use std::sync::atomic::Ordering;
1045
1046        if self.poisoned.load(Ordering::Relaxed) {
1047            return Err(MongrelError::Other(
1048                "database poisoned by fsync error".into(),
1049            ));
1050        }
1051
1052        // ── 1. Prepare: derive write keys + allocate row ids ──
1053        let write_keys = {
1054            let cat = self.catalog.read();
1055            let mut keys: Vec<WriteKey> = Vec::new();
1056            for (table_id, staged) in &staging {
1057                match staged {
1058                    Staged::Put(cells) => {
1059                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
1060                            for col in &entry.schema.columns {
1061                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
1062                                    if let Some((_, val)) =
1063                                        cells.iter().find(|(id, _)| *id == col.id)
1064                                    {
1065                                        let mut h = DefaultHasher::new();
1066                                        val.encode_key().hash(&mut h);
1067                                        keys.push(WriteKey::Unique {
1068                                            table_id: *table_id,
1069                                            index_id: 0,
1070                                            key_hash: h.finish(),
1071                                        });
1072                                    }
1073                                }
1074                            }
1075                            // Declared non-PK unique constraints register a
1076                            // `WriteKey::Unique` (namespace-separated from the
1077                            // PK's index_id==0 by setting the high bit) so two
1078                            // concurrent transactions inserting the same key
1079                            // cannot both commit. Rows with any NULL constrained
1080                            // column are skipped (SQL semantics).
1081                            for uc in &entry.schema.constraints.uniques {
1082                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
1083                                    &uc.columns,
1084                                    &cells.iter().cloned().collect(),
1085                                ) {
1086                                    let mut h = DefaultHasher::new();
1087                                    key_bytes.hash(&mut h);
1088                                    keys.push(WriteKey::Unique {
1089                                        table_id: *table_id,
1090                                        index_id: uc.id | 0x8000,
1091                                        key_hash: h.finish(),
1092                                    });
1093                                }
1094                            }
1095                        }
1096                    }
1097                    Staged::Delete(rid) => keys.push(WriteKey::Row {
1098                        table_id: *table_id,
1099                        row_id: rid.0,
1100                    }),
1101                    Staged::Truncate => keys.push(WriteKey::Table {
1102                        table_id: *table_id,
1103                    }),
1104                }
1105            }
1106            keys
1107        };
1108
1109        // Opportunistic pruning.
1110        let min_active = self.active_txns.min_read_epoch();
1111        if min_active < u64::MAX {
1112            self.conflicts.prune_below(Epoch(min_active));
1113        }
1114
1115        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
1116        // §8.5, review fix #17). Snapshot the conflict-index version so the
1117        // sequencer only re-checks if new commits arrived in the interim.
1118        if self.conflicts.conflicts(&write_keys, read_epoch) {
1119            return Err(MongrelError::Conflict(
1120                "write-write conflict (pre-validate, first-committer-wins)".into(),
1121            ));
1122        }
1123        let pre_validate_version = self.conflicts.version();
1124
1125        // ── 1a½. Fill `AUTO_INCREMENT` columns for every staged put. This must
1126        // happen before row ids are allocated and before rows are serialized
1127        // (either as streamed Put records or spilled runs), so raw transaction
1128        // users get engine-assigned ids just like single-table `put_returning`.
1129        {
1130            let tables = self.tables.read();
1131            for (table_id, staged) in &mut staging {
1132                if let Staged::Put(cells) = staged {
1133                    if let Some(handle) = tables.get(table_id) {
1134                        let mut t = handle.lock();
1135                        t.fill_auto_inc(cells)?;
1136                    }
1137                }
1138            }
1139        }
1140
1141        // ── 1a¾. Validate declarative constraints (unique / FK / check) under
1142        // the read snapshot, outside the WAL mutex. This is the authoritative
1143        // server-side enforcement point: a batch that reaches commit either
1144        // satisfies every declared constraint or is rejected atomically — no
1145        // partial commit. Concurrent-txn uniqueness is additionally guarded by
1146        // the `WriteKey::Unique` keys derived above.
1147        self.validate_constraints(&mut staging, read_epoch)?;
1148
1149        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
1150        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
1151        // streamed as Put records; they are linked at publish time.
1152        let mut spilled: Vec<SpilledRun> = Vec::new();
1153        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
1154        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
1155        // as the spill runs are live (registered on first spill, dropped at the
1156        // end of this function on commit/abort/error).
1157        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
1158        {
1159            let mut table_bytes: HashMap<u64, usize> = HashMap::new();
1160            for (table_id, staged) in &staging {
1161                if let Staged::Put(cells) = staged {
1162                    *table_bytes.entry(*table_id).or_default() += cells.len() * 16;
1163                }
1164            }
1165            let tables = self.tables.read();
1166            for (&table_id, &bytes) in &table_bytes {
1167                if bytes as u64
1168                    <= self
1169                        .spill_threshold
1170                        .load(std::sync::atomic::Ordering::Relaxed)
1171                {
1172                    continue;
1173                }
1174                let Some(handle) = tables.get(&table_id) else {
1175                    continue;
1176                };
1177                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
1178                let mut t = handle.lock();
1179                let tdir = t.table_dir().to_path_buf();
1180                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
1181                std::fs::create_dir_all(&txn_dir)?;
1182                let run_id = t.alloc_run_id() as u128;
1183                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
1184
1185                let mut rows: Vec<Row> = Vec::new();
1186                for (tid, staged) in &staging {
1187                    if *tid != table_id {
1188                        continue;
1189                    }
1190                    if let Staged::Put(cells) = staged {
1191                        t.validate_cells_not_null(cells)?;
1192                        let row_id = t.alloc_row_id();
1193                        let mut row = Row::new(row_id, Epoch(0));
1194                        for (c, v) in cells {
1195                            row.columns.insert(*c, v.clone());
1196                        }
1197                        rows.push(row);
1198                    }
1199                }
1200                let schema = t.schema_ref().clone();
1201                let kek = t.kek_ref().cloned();
1202                let specs = t.indexable_column_specs();
1203                drop(t);
1204
1205                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
1206                    .uniform_epoch(true);
1207                if let Some(ref kek) = kek {
1208                    writer = writer.with_encryption(kek.as_ref(), specs);
1209                }
1210                let header = writer.write(&pending_path, &rows)?;
1211                let row_count = header.row_count;
1212                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
1213                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
1214
1215                spilled.push(SpilledRun {
1216                    table_id,
1217                    run_id,
1218                    pending_path,
1219                    rows,
1220                    row_count,
1221                    min_rid,
1222                    max_rid,
1223                });
1224                spilled_tables.insert(table_id);
1225            }
1226        }
1227
1228        // Test seam: let a test race `gc()` against this in-flight spill.
1229        if spill_guard.is_some() {
1230            if let Some(hook) = self.spill_hook.lock().as_ref() {
1231                hook();
1232            }
1233        }
1234
1235        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
1236        // Allocating row ids + building the rows here (lock order: table handle →
1237        // nothing) means the sequencer never locks a table handle while holding
1238        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
1239        // the table handle THEN the shared WAL; if the sequencer did the reverse
1240        // (WAL then handle) the two paths would deadlock (review fix: B1).
1241        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
1242        // Row ids are allocated here, before the sequencer's delta conflict
1243        // re-check, so a losing txn leaks the ids it reserved — harmless, the
1244        // u64 row-id space is monotonic and gaps are expected (spills do the same).
1245        let mut prebuilt: Vec<Option<Row>> = Vec::with_capacity(staging.len());
1246        {
1247            let tables = self.tables.read();
1248            for (table_id, staged) in &staging {
1249                match staged {
1250                    Staged::Put(cells) if !spilled_tables.contains(table_id) => {
1251                        let handle = tables.get(table_id).ok_or_else(|| {
1252                            MongrelError::NotFound(format!("table {table_id} not mounted"))
1253                        })?;
1254                        let mut t = handle.lock();
1255                        t.validate_cells_not_null(cells)?;
1256                        let row_id = t.alloc_row_id();
1257                        drop(t);
1258                        let mut row = Row::new(row_id, Epoch(0));
1259                        for (c, v) in cells {
1260                            row.columns.insert(*c, v.clone());
1261                        }
1262                        prebuilt.push(Some(row));
1263                    }
1264                    Staged::Put(_) | Staged::Delete(_) | Staged::Truncate => prebuilt.push(None),
1265                }
1266            }
1267        }
1268
1269        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
1270        let added_runs: Vec<crate::wal::AddedRun> = spilled
1271            .iter()
1272            .map(|s| crate::wal::AddedRun {
1273                table_id: s.table_id,
1274                run_id: s.run_id,
1275                row_count: s.row_count,
1276                level: 0,
1277                min_row_id: s.min_rid,
1278                max_row_id: s.max_rid,
1279                content_hash: [0u8; 32],
1280            })
1281            .collect();
1282        let (new_epoch, applies, commit_seq) = {
1283            let mut wal = self.shared_wal.lock();
1284
1285            // Re-check only if the conflict index advanced since pre-validation
1286            // (bounded delta — spec §8.5, review fix #17). If the version is
1287            // unchanged, the pre-check result is still valid and the sequencer
1288            // does O(1) work regardless of write-set size.
1289            if self.conflicts.version() != pre_validate_version
1290                && self.conflicts.conflicts(&write_keys, read_epoch)
1291            {
1292                // Abort: this txn assigned no epoch yet, so drop the quarantined
1293                // spill runs we wrote during prepare instead of leaking them in
1294                // `_txn/` until the next GC/reopen sweep.
1295                drop(wal);
1296                for s in &spilled {
1297                    if let Some(parent) = s.pending_path.parent() {
1298                        let _ = std::fs::remove_dir_all(parent);
1299                    }
1300                }
1301                return Err(MongrelError::Conflict(
1302                    "write-write conflict (sequencer delta re-check)".into(),
1303                ));
1304            }
1305
1306            let new_epoch = self.epoch.bump_assigned();
1307            let mut applies: Vec<(u64, Vec<StagedOp>)> = Vec::new();
1308
1309            for (idx, (table_id, staged)) in staging.iter().enumerate() {
1310                // Skip puts for tables that were spilled — their data is in a
1311                // pending run, not in streamed Put records.
1312                if spilled_tables.contains(table_id) && matches!(staged, Staged::Put(_)) {
1313                    continue;
1314                }
1315                let mut ops = Vec::new();
1316                match staged {
1317                    Staged::Put(_) => {
1318                        // Stamp the pre-built row at the real assigned epoch.
1319                        let mut row = prebuilt[idx].take().expect("prebuilt put row");
1320                        row.committed_epoch = new_epoch;
1321                        let payload = bincode::serialize(&vec![row.clone()])
1322                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
1323                        wal.append(
1324                            txn_id,
1325                            *table_id,
1326                            Op::Put {
1327                                table_id: *table_id,
1328                                rows: payload,
1329                            },
1330                        )?;
1331                        ops.push(StagedOp::Put(row));
1332                    }
1333                    Staged::Delete(rid) => {
1334                        wal.append(
1335                            txn_id,
1336                            *table_id,
1337                            Op::Delete {
1338                                table_id: *table_id,
1339                                row_ids: vec![*rid],
1340                            },
1341                        )?;
1342                        ops.push(StagedOp::Delete(*rid));
1343                    }
1344                    Staged::Truncate => {
1345                        wal.append(
1346                            txn_id,
1347                            *table_id,
1348                            Op::TruncateTable {
1349                                table_id: *table_id,
1350                            },
1351                        )?;
1352                        ops.push(StagedOp::Truncate);
1353                    }
1354                }
1355                applies.push((*table_id, ops));
1356            }
1357
1358            let commit_seq = wal.append_commit(txn_id, new_epoch, &added_runs)?;
1359
1360            // Record the conflict + assign the epoch under the WAL lock so commit
1361            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
1362            // moves out of this critical section to the group-commit coordinator
1363            // so concurrent committers share a single leader fsync.
1364            self.conflicts.record(&write_keys, new_epoch);
1365            (new_epoch, applies, commit_seq)
1366        };
1367
1368        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
1369        self.group
1370            .await_durable(&self.shared_wal, commit_seq)
1371            .inspect_err(|_| {
1372                self.poisoned.store(true, Ordering::Relaxed);
1373            })?;
1374
1375        // ── 3. Publish: link spilled runs + apply non-spilled ops ──
1376        {
1377            let tables = self.tables.read();
1378            // Link spilled runs first.
1379            for s in &spilled {
1380                if let Some(handle) = tables.get(&s.table_id) {
1381                    let mut t = handle.lock();
1382                    let dest = t.run_path(s.run_id as u64);
1383                    std::fs::rename(&s.pending_path, &dest)?;
1384                    // Clean up the now-empty `_txn/<txn_id>/` dir.
1385                    if let Some(parent) = s.pending_path.parent() {
1386                        let _ = std::fs::remove_dir_all(parent);
1387                    }
1388                    t.link_run(crate::manifest::RunRef {
1389                        run_id: s.run_id,
1390                        level: 0,
1391                        epoch_created: new_epoch.0,
1392                        row_count: s.row_count,
1393                    });
1394                    // Update indexes + live_count from the spilled rows WITHOUT
1395                    // materializing them in the memtable (P3.4): they are served
1396                    // from the linked uniform-epoch run, read at `new_epoch` via
1397                    // the RunRef. This keeps peak memory bounded for large txns.
1398                    t.apply_run_metadata(&s.rows)?;
1399                    t.invalidate_pending_cache();
1400                    t.persist_manifest(new_epoch)?;
1401                }
1402            }
1403            // Apply non-spilled ops.
1404            for (table_id, ops) in applies {
1405                if let Some(handle) = tables.get(&table_id) {
1406                    let mut t = handle.lock();
1407                    for op in ops {
1408                        match op {
1409                            StagedOp::Put(row) => t.apply_put_rows(vec![row])?,
1410                            StagedOp::Delete(rid) => t.apply_delete(rid, new_epoch),
1411                            StagedOp::Truncate => t.apply_truncate(new_epoch)?,
1412                        }
1413                    }
1414                    t.invalidate_pending_cache();
1415                    t.persist_manifest(new_epoch)?;
1416                }
1417            }
1418        }
1419
1420        self.advance_visible(new_epoch);
1421        Ok(new_epoch)
1422    }
1423
1424    /// Advance `visible` in-order: epoch E becomes visible only once E and all
1425    /// prior unpublished epochs have finished publishing (spec §9.3e). The
1426    /// in-order gate lives on the shared [`EpochAuthority`] so this path, the
1427    /// single-table `Table::commit` path, and DDL all share one watermark and
1428    /// can never publish out of assigned order under concurrency.
1429    fn advance_visible(&self, published: Epoch) {
1430        self.epoch.publish_in_order(published);
1431    }
1432
1433    /// Register a read snapshot at the current visible epoch and return it with
1434    /// a guard that retains it for GC until dropped.
1435    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
1436        let e = self.epoch.visible();
1437        let g = self.snapshots.register(e);
1438        (Snapshot::at(e), g)
1439    }
1440
1441    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
1442    /// retention.
1443    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
1444        let e = self.epoch.visible();
1445        let g = self.snapshots.register_owned(e);
1446        (Snapshot::at(e), g)
1447    }
1448
1449    /// Names of all live tables.
1450    pub fn table_names(&self) -> Vec<String> {
1451        self.catalog
1452            .read()
1453            .tables
1454            .iter()
1455            .filter(|t| matches!(t.state, TableState::Live))
1456            .map(|t| t.name.clone())
1457            .collect()
1458    }
1459
1460    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
1461    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
1462    /// reaped on the next open. Call this as the last action before a
1463    /// short-lived process (CLI, one-shot script) exits. The daemon does not
1464    /// need this — its background auto-compactor handles run management.
1465    pub fn close(&self) -> Result<()> {
1466        for name in self.table_names() {
1467            if let Ok(handle) = self.table(&name) {
1468                if let Err(e) = handle.lock().close() {
1469                    eprintln!("[close] flush failed for {name}: {e}");
1470                }
1471            }
1472        }
1473        Ok(())
1474    }
1475
1476    /// Compact every mounted table: merge all sorted runs into one clean run
1477    /// so query cost stays flat (single-run fast path) instead of growing
1478    /// with run count. Tables with < 2 runs are skipped (no-op). Each table
1479    /// is locked individually for its own compaction; snapshot retention is
1480    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
1481    pub fn compact(&self) -> Result<(usize, usize)> {
1482        let mut compacted = 0;
1483        let mut skipped = 0;
1484        for name in self.table_names() {
1485            let Ok(handle) = self.table(&name) else {
1486                continue;
1487            };
1488            {
1489                let mut t = handle.lock();
1490                let before = t.run_count();
1491                if before < 2 {
1492                    skipped += 1;
1493                    continue;
1494                }
1495                match t.compact() {
1496                    Ok(()) => {
1497                        let after = t.run_count();
1498                        compacted += 1;
1499                        eprintln!("[compact] {name}: {before} -> {after} runs");
1500                    }
1501                    Err(e) => {
1502                        eprintln!("[compact] {name}: compaction failed: {e}");
1503                        skipped += 1;
1504                    }
1505                }
1506            }
1507        }
1508        Ok((compacted, skipped))
1509    }
1510
1511    /// Compact a single table by name. Returns `Ok(true)` if it was
1512    /// compacted, `Ok(false)` if skipped (< 2 runs).
1513    pub fn compact_table(&self, name: &str) -> Result<bool> {
1514        let handle = self.table(name)?;
1515        let mut t = handle.lock();
1516        let before = t.run_count();
1517        if before < 2 {
1518            return Ok(false);
1519        }
1520        t.compact()?;
1521        Ok(t.run_count() < before)
1522    }
1523
1524    /// Look up a live table by name.
1525    pub fn table(&self, name: &str) -> Result<TableHandle> {
1526        let cat = self.catalog.read();
1527        let entry = cat
1528            .live(name)
1529            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
1530        let id = entry.table_id;
1531        drop(cat);
1532        self.tables
1533            .read()
1534            .get(&id)
1535            .cloned()
1536            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
1537    }
1538
1539    /// Resolve a live table id → mounted handle (used by the constraint
1540    /// validation pass and other id-qualified internal paths).
1541    fn table_by_id(&self, id: u64) -> Result<TableHandle> {
1542        self.tables
1543            .read()
1544            .get(&id)
1545            .cloned()
1546            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
1547    }
1548
1549    /// Create a new table. The DDL is first logged to the shared WAL
1550    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
1551    /// BEFORE the in-memory catalog and table map are mutated; the catalog
1552    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
1553    /// that sees a stale catalog still recovers the table by replaying the Ddl.
1554    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
1555        use crate::wal::DdlOp;
1556        use std::sync::atomic::Ordering;
1557
1558        if self.poisoned.load(Ordering::Relaxed) {
1559            return Err(MongrelError::Other(
1560                "database poisoned by fsync error".into(),
1561            ));
1562        }
1563
1564        let _g = self.ddl_lock.lock();
1565        {
1566            let cat = self.catalog.read();
1567            if cat.live(name).is_some() {
1568                return Err(MongrelError::InvalidArgument(format!(
1569                    "table {name:?} already exists"
1570                )));
1571            }
1572        }
1573
1574        // Allocate id + epoch + txn id under the commit lock so the DDL commit
1575        // is serialized with data commits (in-order publish).
1576        let commit_lock = Arc::clone(&self.commit_lock);
1577        let _c = commit_lock.lock();
1578        let table_id = {
1579            let mut cat = self.catalog.write();
1580            let id = cat.next_table_id;
1581            cat.next_table_id += 1;
1582            id
1583        };
1584        let epoch = self.epoch.bump_assigned();
1585        let txn_id = self.alloc_txn_id();
1586
1587        // Stamp the schema_id with the unique table_id so every table in the
1588        // database has a distinct schema_id (caller-provided values are
1589        // ignored to prevent collisions).
1590        let mut schema = schema;
1591        schema.schema_id = table_id;
1592        // Defense in depth: reject an invalid schema BEFORE any durable
1593        // side-effect. `Table::create_in` re-validates, but by then the DDL has
1594        // already been appended to the shared WAL; a failing create_in would
1595        // leave a dangling entry that `recover_ddl_from_wal` replays without
1596        // re-validating, corrupting the catalog on reopen. Validating here
1597        // keeps the WAL free of schemas that can never be opened.
1598        schema.validate_auto_increment()?;
1599
1600        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
1601        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
1602        let schema_json = DdlOp::encode_schema(&schema)?;
1603        let commit_seq = {
1604            let mut wal = self.shared_wal.lock();
1605            wal.append(
1606                txn_id,
1607                table_id,
1608                crate::wal::Op::Ddl(DdlOp::CreateTable {
1609                    table_id,
1610                    name: name.to_string(),
1611                    schema_json,
1612                }),
1613            )?;
1614            wal.append_commit(txn_id, epoch, &[])?
1615        };
1616        self.group
1617            .await_durable(&self.shared_wal, commit_seq)
1618            .inspect_err(|_| {
1619                self.poisoned.store(true, Ordering::Relaxed);
1620            })?;
1621
1622        // 2. Create the on-disk table dir + manifest.
1623        let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
1624        std::fs::create_dir_all(&tdir)?;
1625        let ctx = SharedCtx {
1626            epoch: Arc::clone(&self.epoch),
1627            page_cache: Arc::clone(&self.page_cache),
1628            decoded_cache: Arc::clone(&self.decoded_cache),
1629            snapshots: Arc::clone(&self.snapshots),
1630            kek: self.kek.clone(),
1631            commit_lock: Arc::clone(&self.commit_lock),
1632            shared: Some(crate::engine::SharedWalCtx {
1633                wal: Arc::clone(&self.shared_wal),
1634                group: Arc::clone(&self.group),
1635                poisoned: Arc::clone(&self.poisoned),
1636                txn_ids: Arc::clone(&self.next_txn_id),
1637            }),
1638        };
1639        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
1640
1641        // 3. Mutate the in-memory catalog + mount the table, then rewrite the
1642        //    catalog checkpoint (lazy: outside the WAL critical section).
1643        {
1644            let mut cat = self.catalog.write();
1645            cat.tables.push(CatalogEntry {
1646                table_id,
1647                name: name.to_string(),
1648                schema,
1649                state: TableState::Live,
1650                created_epoch: epoch.0,
1651            });
1652        }
1653        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1654        self.tables
1655            .write()
1656            .insert(table_id, Arc::new(Mutex::new(table)));
1657
1658        self.advance_visible(epoch);
1659        Ok(table_id)
1660    }
1661
1662    /// Logically drop a table, logging the DDL through the shared WAL first.
1663    pub fn drop_table(&self, name: &str) -> Result<()> {
1664        use crate::wal::DdlOp;
1665        use std::sync::atomic::Ordering;
1666
1667        if self.poisoned.load(Ordering::Relaxed) {
1668            return Err(MongrelError::Other(
1669                "database poisoned by fsync error".into(),
1670            ));
1671        }
1672
1673        let _g = self.ddl_lock.lock();
1674        let table_id = {
1675            let cat = self.catalog.read();
1676            cat.live(name)
1677                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
1678                .table_id
1679        };
1680
1681        let commit_lock = Arc::clone(&self.commit_lock);
1682        let _c = commit_lock.lock();
1683        let epoch = self.epoch.bump_assigned();
1684        let txn_id = self.alloc_txn_id();
1685        let commit_seq = {
1686            let mut wal = self.shared_wal.lock();
1687            wal.append(
1688                txn_id,
1689                table_id,
1690                crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
1691            )?;
1692            wal.append_commit(txn_id, epoch, &[])?
1693        };
1694        self.group
1695            .await_durable(&self.shared_wal, commit_seq)
1696            .inspect_err(|_| {
1697                self.poisoned.store(true, Ordering::Relaxed);
1698            })?;
1699
1700        {
1701            let mut cat = self.catalog.write();
1702            let entry = cat
1703                .tables
1704                .iter_mut()
1705                .find(|t| t.table_id == table_id)
1706                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
1707            entry.state = TableState::Dropped { at_epoch: epoch.0 };
1708        }
1709        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1710        self.tables.write().remove(&table_id);
1711
1712        self.advance_visible(epoch);
1713        Ok(())
1714    }
1715
1716    /// Rename a live table. `name` must exist and `new_name` must not collide
1717    /// with any live table; both checks run under `ddl_lock` so they are atomic
1718    /// with the rename and with concurrent `create_table` existence checks (no
1719    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
1720    /// side-effects. The rename is logged to the shared WAL as
1721    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
1722    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
1723    /// the in-memory object does not move — only the catalog name changes).
1724    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
1725        use crate::wal::DdlOp;
1726        use std::sync::atomic::Ordering;
1727
1728        if self.poisoned.load(Ordering::Relaxed) {
1729            return Err(MongrelError::Other(
1730                "database poisoned by fsync error".into(),
1731            ));
1732        }
1733
1734        // A no-op rename short-circuits before any locking, so it can never
1735        // trip the "target already exists" check (the source *is* that name).
1736        if name == new_name {
1737            return Ok(());
1738        }
1739        if new_name.is_empty() {
1740            return Err(MongrelError::InvalidArgument(
1741                "rename_table: new name must not be empty".into(),
1742            ));
1743        }
1744
1745        let _g = self.ddl_lock.lock();
1746        let table_id = {
1747            let cat = self.catalog.read();
1748            let src = cat
1749                .live(name)
1750                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
1751            // Target must be free. Checked under ddl_lock, which every other
1752            // DDL (create/rename/drop) also holds, so a concurrent operation
1753            // cannot claim `new_name` between this check and the catalog write.
1754            if cat.live(new_name).is_some() {
1755                return Err(MongrelError::InvalidArgument(format!(
1756                    "rename_table: a table named {new_name:?} already exists"
1757                )));
1758            }
1759            src.table_id
1760        };
1761
1762        let commit_lock = Arc::clone(&self.commit_lock);
1763        let _c = commit_lock.lock();
1764        let epoch = self.epoch.bump_assigned();
1765        let txn_id = self.alloc_txn_id();
1766        let commit_seq = {
1767            let mut wal = self.shared_wal.lock();
1768            wal.append(
1769                txn_id,
1770                table_id,
1771                crate::wal::Op::Ddl(DdlOp::RenameTable {
1772                    table_id,
1773                    new_name: new_name.to_string(),
1774                }),
1775            )?;
1776            wal.append_commit(txn_id, epoch, &[])?
1777        };
1778        self.group
1779            .await_durable(&self.shared_wal, commit_seq)
1780            .inspect_err(|_| {
1781                self.poisoned.store(true, Ordering::Relaxed);
1782            })?;
1783
1784        {
1785            let mut cat = self.catalog.write();
1786            let entry = cat
1787                .tables
1788                .iter_mut()
1789                .find(|t| t.table_id == table_id)
1790                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
1791            entry.name = new_name.to_string();
1792        }
1793        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1794        // The in-memory table object is keyed by table_id, not name, so it does
1795        // not move and live TableHandles remain valid.
1796        self.advance_visible(epoch);
1797        Ok(())
1798    }
1799
1800    pub fn alter_column(
1801        &self,
1802        table_name: &str,
1803        column_name: &str,
1804        change: AlterColumn,
1805    ) -> Result<ColumnDef> {
1806        use crate::wal::DdlOp;
1807        use std::sync::atomic::Ordering;
1808
1809        if self.poisoned.load(Ordering::Relaxed) {
1810            return Err(MongrelError::Other(
1811                "database poisoned by fsync error".into(),
1812            ));
1813        }
1814
1815        let _g = self.ddl_lock.lock();
1816        let table_id = {
1817            let cat = self.catalog.read();
1818            cat.live(table_name)
1819                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
1820                .table_id
1821        };
1822        let handle =
1823            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
1824                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
1825            })?;
1826        let mut table = handle.lock();
1827        let column = table.prepare_alter_column(column_name, &change)?;
1828        if table
1829            .schema()
1830            .columns
1831            .iter()
1832            .find(|c| c.id == column.id)
1833            .is_some_and(|c| c == &column)
1834        {
1835            return Ok(column);
1836        }
1837
1838        let commit_lock = Arc::clone(&self.commit_lock);
1839        let _c = commit_lock.lock();
1840        let epoch = self.epoch.bump_assigned();
1841        let txn_id = self.alloc_txn_id();
1842        let column_json = DdlOp::encode_column(&column)?;
1843        let commit_seq = {
1844            let mut wal = self.shared_wal.lock();
1845            wal.append(
1846                txn_id,
1847                table_id,
1848                crate::wal::Op::Ddl(DdlOp::AlterTable {
1849                    table_id,
1850                    column_json,
1851                }),
1852            )?;
1853            wal.append_commit(txn_id, epoch, &[])?
1854        };
1855        self.group
1856            .await_durable(&self.shared_wal, commit_seq)
1857            .inspect_err(|_| {
1858                self.poisoned.store(true, Ordering::Relaxed);
1859            })?;
1860
1861        table.apply_altered_column(column.clone())?;
1862        let schema = table.schema().clone();
1863        drop(table);
1864
1865        {
1866            let mut cat = self.catalog.write();
1867            let entry = cat
1868                .tables
1869                .iter_mut()
1870                .find(|t| t.table_id == table_id)
1871                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
1872            entry.schema = schema;
1873        }
1874        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1875
1876        self.advance_visible(epoch);
1877        Ok(column)
1878    }
1879
1880    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
1881    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
1882    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
1883    ///
1884    /// Returns the number of items reclaimed.
1885    pub fn gc(&self) -> Result<usize> {
1886        let min_active = self.snapshots.min_active(self.epoch.visible()).0;
1887        let mut reclaimed = 0;
1888
1889        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
1890        let cat = self.catalog.read();
1891        for entry in &cat.tables {
1892            if let TableState::Dropped { at_epoch } = entry.state {
1893                if at_epoch <= min_active {
1894                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
1895                    if tdir.exists() {
1896                        std::fs::remove_dir_all(&tdir)?;
1897                        reclaimed += 1;
1898                    }
1899                }
1900            }
1901        }
1902        drop(cat);
1903
1904        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
1905        // in-flight spill's dir (deleting it would lose the pending run and fail
1906        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
1907        // skip any id still registered in `active_spills`.
1908        let cat = self.catalog.read();
1909        for entry in &cat.tables {
1910            if !matches!(entry.state, TableState::Live) {
1911                continue;
1912            }
1913            let txn_dir = self
1914                .root
1915                .join(TABLES_DIR)
1916                .join(entry.table_id.to_string())
1917                .join("_txn");
1918            if !txn_dir.exists() {
1919                continue;
1920            }
1921            for sub in std::fs::read_dir(&txn_dir)? {
1922                let sub = sub?;
1923                let name = sub.file_name();
1924                let Some(name) = name.to_str() else { continue };
1925                // A non-numeric entry can't belong to a live txn — sweep it.
1926                let is_active = name
1927                    .parse::<u64>()
1928                    .map(|id| self.active_spills.is_active(id))
1929                    .unwrap_or(false);
1930                if is_active {
1931                    continue;
1932                }
1933                std::fs::remove_dir_all(sub.path())?;
1934                reclaimed += 1;
1935            }
1936        }
1937        drop(cat);
1938
1939        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
1940        // can still need (spec §6.4). Each table deletes its own retired files
1941        // gated on `min_active` and persists its manifest.
1942        let tables = self.tables.read();
1943        for handle in tables.values() {
1944            reclaimed += handle.lock().reap_retiring(Epoch(min_active))?;
1945        }
1946
1947        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
1948        // segment on every reopen without truncating the prior ones, so rotated
1949        // segments accumulate. Once every live table's committed data is durable
1950        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
1951        // (non-active) segments are redundant for recovery and safe to delete —
1952        // an in-flight txn only ever appends to the active segment, which is
1953        // never deleted.
1954        let all_durable = self.active_spills.is_idle()
1955            && tables.values().all(|h| {
1956                let g = h.lock();
1957                g.memtable_len() == 0 && g.mutable_run_len() == 0
1958            });
1959        drop(tables);
1960        if all_durable {
1961            reclaimed += self.shared_wal.lock().gc_segments(u64::MAX)?;
1962        }
1963
1964        Ok(reclaimed)
1965    }
1966    fn alloc_txn_id(&self) -> u64 {
1967        let mut g = self.next_txn_id.lock();
1968        let id = *g;
1969        *g = g.wrapping_add(1);
1970        id
1971    }
1972
1973    /// Set the per-table spill threshold (bytes). When a transaction's staged
1974    /// bytes for a single table exceed this, the rows are written as a
1975    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
1976    pub fn set_spill_threshold(&self, bytes: u64) {
1977        self.spill_threshold
1978            .store(bytes, std::sync::atomic::Ordering::Relaxed);
1979    }
1980
1981    /// Test-only: install a hook invoked after a transaction writes its spill
1982    /// runs but before the sequencer, so a test can race `gc()` against an
1983    /// in-flight spill. Not part of the stable API.
1984    #[doc(hidden)]
1985    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
1986        *self.spill_hook.lock() = Some(Box::new(f));
1987    }
1988
1989    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
1990    /// this stays well below the number of committed transactions when commits
1991    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
1992    #[doc(hidden)]
1993    pub fn __wal_group_sync_count(&self) -> u64 {
1994        self.shared_wal.lock().group_sync_count()
1995    }
1996
1997    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
1998    /// contract that an fsync error would trigger in production.
1999    #[doc(hidden)]
2000    pub fn __poison(&self) {
2001        self.poisoned
2002            .store(true, std::sync::atomic::Ordering::Relaxed);
2003    }
2004
2005    /// Verify multi-table integrity (spec §16). For every live table this:
2006    /// authenticates the manifest; opens each `RunRef`'s file through
2007    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
2008    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
2009    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
2010    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
2011    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
2012    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
2013    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
2014    ///
2015    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
2016    /// full body, so this is an integrity tool, not a hot path.
2017    pub fn check(&self) -> Vec<CheckIssue> {
2018        let mut issues = Vec::new();
2019        let cat = self.catalog.read();
2020        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
2021        for entry in &cat.tables {
2022            if !matches!(entry.state, TableState::Live) {
2023                continue;
2024            }
2025            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
2026            let mut err = |sev: &str, desc: String| {
2027                issues.push(CheckIssue {
2028                    table_id: entry.table_id,
2029                    table_name: entry.name.clone(),
2030                    severity: sev.into(),
2031                    description: desc,
2032                });
2033            };
2034            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
2035                Ok(m) => m,
2036                Err(e) => {
2037                    err("error", format!("manifest read failed: {e}"));
2038                    continue;
2039                }
2040            };
2041            if m.flushed_epoch > m.current_epoch {
2042                err(
2043                    "error",
2044                    format!(
2045                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
2046                        m.flushed_epoch, m.current_epoch
2047                    ),
2048                );
2049            }
2050
2051            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
2052            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
2053            for rr in &m.runs {
2054                referenced.insert(rr.run_id);
2055                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
2056                if !run_path.exists() {
2057                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
2058                    continue;
2059                }
2060                match crate::sorted_run::RunReader::open(
2061                    &run_path,
2062                    entry.schema.clone(),
2063                    self.kek.clone(),
2064                ) {
2065                    Ok(reader) => {
2066                        if reader.row_count() as u64 != rr.row_count {
2067                            err(
2068                                "error",
2069                                format!(
2070                                    "run r-{} row count mismatch: manifest {} vs run {}",
2071                                    rr.run_id,
2072                                    rr.row_count,
2073                                    reader.row_count()
2074                                ),
2075                            );
2076                        }
2077                    }
2078                    Err(e) => {
2079                        err(
2080                            "error",
2081                            format!("run r-{} integrity check failed: {e}", rr.run_id),
2082                        );
2083                    }
2084                }
2085            }
2086
2087            // Compaction-superseded runs awaiting retention-gated deletion are
2088            // tracked in `retiring`; their files are expected on disk, so they
2089            // are not orphans.
2090            for r in &m.retiring {
2091                referenced.insert(r.run_id);
2092            }
2093
2094            // Orphan `.sr` files present on disk but absent from the manifest.
2095            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
2096                for ent in rd.flatten() {
2097                    let p = ent.path();
2098                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
2099                        continue;
2100                    }
2101                    let run_id = p
2102                        .file_stem()
2103                        .and_then(|s| s.to_str())
2104                        .and_then(|s| s.strip_prefix("r-"))
2105                        .and_then(|s| s.parse::<u128>().ok());
2106                    if let Some(id) = run_id {
2107                        if !referenced.contains(&id) {
2108                            err(
2109                                "warning",
2110                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
2111                            );
2112                        }
2113                    }
2114                }
2115            }
2116        }
2117
2118        // WAL retention / integrity invariant (spec §16): every on-disk WAL
2119        // segment must open (header magic + version, and the frame cipher must
2120        // be derivable for an encrypted WAL). A segment that won't open is
2121        // corrupt or truncated and would break crash recovery. `table_id` is
2122        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
2123        // never confuses a WAL issue with a real table.
2124        for (seg, msg) in self.shared_wal.lock().verify_segments() {
2125            issues.push(CheckIssue {
2126                table_id: WAL_TABLE_ID,
2127                table_name: "<wal>".into(),
2128                severity: "error".into(),
2129                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
2130            });
2131        }
2132        issues
2133    }
2134
2135    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
2136    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
2137    /// unmounts them from the live table map so the DB still opens.
2138    pub fn doctor(&self) -> Result<Vec<u64>> {
2139        // Hold the DDL lock for the whole operation to prevent concurrent
2140        // create_table/drop_table from racing the catalog/dir mutation.
2141        let _ddl = self.ddl_lock.lock();
2142        let issues = self.check();
2143        // A corrupt WAL segment is reported as an error but is NOT a table
2144        // problem — quarantining an innocent table cannot fix it (and the first
2145        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
2146        // them disjoint). The admin must address WAL corruption manually.
2147        let bad_tables: std::collections::HashSet<u64> = issues
2148            .iter()
2149            .filter(|i| i.severity == "error" && i.table_id != WAL_TABLE_ID)
2150            .map(|i| i.table_id)
2151            .collect();
2152        if bad_tables.is_empty() {
2153            return Ok(Vec::new());
2154        }
2155
2156        let qdir = self.root.join("_quarantine");
2157        std::fs::create_dir_all(&qdir)?;
2158        let mut quarantined = Vec::new();
2159        for &table_id in &bad_tables {
2160            let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
2161            if tdir.exists() {
2162                let dest = qdir.join(table_id.to_string());
2163                std::fs::rename(&tdir, &dest)?;
2164            }
2165            {
2166                let mut cat = self.catalog.write();
2167                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
2168                    entry.state = TableState::Dropped {
2169                        at_epoch: self.epoch.visible().0,
2170                    };
2171                }
2172            }
2173            // Unmount the live handle so no further access reaches the moved dir.
2174            self.tables.write().remove(&table_id);
2175            quarantined.push(table_id);
2176        }
2177        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2178        Ok(quarantined)
2179    }
2180
2181    /// The DB-wide KEK (if encrypted).
2182    #[allow(dead_code)]
2183    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
2184        self.kek.as_ref()
2185    }
2186
2187    /// Shared epoch authority (used by the transaction layer in P2).
2188    #[allow(dead_code)]
2189    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
2190        &self.epoch
2191    }
2192
2193    /// Shared snapshot registry (used by GC in P3.6).
2194    #[allow(dead_code)]
2195    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
2196        &self.snapshots
2197    }
2198}
2199
2200/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
2201///
2202/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
2203/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
2204/// 2 applies each committed data record (Put/Delete) to its table at the commit
2205/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
2206/// durable in a sorted run). Finally the shared epoch authority is raised to the
2207/// max committed epoch so the next commit continues monotonically.
2208fn recover_shared_wal(
2209    root: &Path,
2210    tables: &HashMap<u64, TableHandle>,
2211    epoch: &EpochAuthority,
2212    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
2213) -> Result<()> {
2214    use crate::memtable::Row;
2215    use crate::rowid::RowId;
2216    use crate::wal::{Op, SharedWal};
2217
2218    let records = SharedWal::replay_with_dek(root, wal_dek)?;
2219
2220    // Pass 1: committed-txn outcomes + collect spilled-run info.
2221    let mut committed: HashMap<u64, u64> = HashMap::new();
2222    let mut spilled_to_link: Vec<(
2223        u64, /*txn_id*/
2224        u64, /*epoch*/
2225        Vec<crate::wal::AddedRun>,
2226    )> = Vec::new();
2227    for r in &records {
2228        if let Op::TxnCommit {
2229            epoch: ce,
2230            ref added_runs,
2231        } = r.op
2232        {
2233            committed.insert(r.txn_id, ce);
2234            if !added_runs.is_empty() {
2235                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
2236            }
2237        }
2238    }
2239
2240    // Pass 2: stage data per table, gated by flushed_epoch.
2241    type TableStage = (Vec<Row>, Vec<(RowId, Epoch)>, Option<Epoch>, Epoch);
2242    let mut stage: HashMap<u64, TableStage> = HashMap::new();
2243    let mut max_epoch = epoch.visible().0;
2244    for r in records {
2245        let Some(&ce) = committed.get(&r.txn_id) else {
2246            continue; // aborted / in-flight — discard
2247        };
2248        let commit_epoch = Epoch(ce);
2249        max_epoch = max_epoch.max(ce);
2250        match r.op {
2251            Op::Put { table_id, rows } => {
2252                // Skip if this table already flushed past the commit epoch.
2253                let skip = tables
2254                    .get(&table_id)
2255                    .map(|h| h.lock().flushed_epoch() >= ce)
2256                    .unwrap_or(true);
2257                if skip {
2258                    continue;
2259                }
2260                let rows: Vec<Row> = match bincode::deserialize(&rows) {
2261                    Ok(v) => v,
2262                    Err(_) => continue,
2263                };
2264                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
2265                // at pending_epoch which equals the commit epoch, but be robust).
2266                let rows: Vec<Row> = rows
2267                    .into_iter()
2268                    .map(|mut row| {
2269                        row.committed_epoch = commit_epoch;
2270                        row
2271                    })
2272                    .collect();
2273                let entry = stage
2274                    .entry(table_id)
2275                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
2276                entry.0.extend(rows);
2277                entry.3 = commit_epoch;
2278            }
2279            Op::Delete { table_id, row_ids } => {
2280                let skip = tables
2281                    .get(&table_id)
2282                    .map(|h| h.lock().flushed_epoch() >= ce)
2283                    .unwrap_or(true);
2284                if skip {
2285                    continue;
2286                }
2287                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
2288                let entry = stage
2289                    .entry(table_id)
2290                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
2291                entry.1.extend(dels);
2292                entry.3 = commit_epoch;
2293            }
2294            Op::TruncateTable { table_id } => {
2295                let skip = tables
2296                    .get(&table_id)
2297                    .map(|h| h.lock().flushed_epoch() >= ce)
2298                    .unwrap_or(true);
2299                if skip {
2300                    continue;
2301                }
2302                stage.insert(
2303                    table_id,
2304                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
2305                );
2306            }
2307            _ => {}
2308        }
2309    }
2310    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
2311        let Some(handle) = tables.get(&table_id) else {
2312            continue;
2313        };
2314        let mut t = handle.lock();
2315        if let Some(epoch) = truncate_epoch {
2316            t.apply_truncate(epoch)?;
2317        }
2318        t.recover_apply(rows, deletes)?;
2319        if truncate_epoch.is_some() {
2320            let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
2321            t.live_count = rows.len() as u64;
2322            t.persist_manifest(table_epoch)?;
2323        }
2324    }
2325
2326    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
2327    // between TxnCommit sync and the publish phase leaves the run in
2328    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
2329    for (txn_id, ce, added_runs) in &spilled_to_link {
2330        for ar in added_runs {
2331            let Some(handle) = tables.get(&ar.table_id) else {
2332                continue;
2333            };
2334            let mut t = handle.lock();
2335            let dest = t.run_path(ar.run_id as u64);
2336            if !dest.exists() {
2337                let pending = root
2338                    .join(TABLES_DIR)
2339                    .join(ar.table_id.to_string())
2340                    .join("_txn")
2341                    .join(txn_id.to_string())
2342                    .join(format!("r-{}.sr", ar.run_id));
2343                if pending.exists() {
2344                    if let Some(parent) = pending.parent() {
2345                        std::fs::rename(&pending, &dest)?;
2346                        let _ = std::fs::remove_dir_all(parent);
2347                    }
2348                }
2349            }
2350            // Only link a run whose file is actually present, and never re-link
2351            // one the publish phase already persisted into the manifest (which is
2352            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
2353            // until segment GC). `recover_spilled_run` is idempotent + reconciles
2354            // `live_count`/indexes only when the run is genuinely new.
2355            if t.run_path(ar.run_id as u64).exists() {
2356                t.recover_spilled_run(crate::manifest::RunRef {
2357                    run_id: ar.run_id,
2358                    level: ar.level,
2359                    epoch_created: *ce,
2360                    row_count: ar.row_count,
2361                });
2362            }
2363        }
2364    }
2365
2366    epoch.advance_recovered(Epoch(max_epoch));
2367    Ok(())
2368}
2369
2370fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
2371    match condition {
2372        ProcedureCondition::Pk { .. } => {
2373            if schema.primary_key().is_none() {
2374                return Err(MongrelError::InvalidArgument(
2375                    "procedure condition Pk references a table without a primary key".into(),
2376                ));
2377            }
2378        }
2379        ProcedureCondition::BitmapEq { column_id, .. }
2380        | ProcedureCondition::BitmapIn { column_id, .. }
2381        | ProcedureCondition::Range { column_id, .. }
2382        | ProcedureCondition::RangeF64 { column_id, .. }
2383        | ProcedureCondition::IsNull { column_id }
2384        | ProcedureCondition::IsNotNull { column_id }
2385        | ProcedureCondition::FmContains { column_id, .. } => {
2386            validate_column_id(*column_id, schema)?;
2387        }
2388    }
2389    Ok(())
2390}
2391
2392fn bind_procedure_args(
2393    procedure: &StoredProcedure,
2394    mut args: HashMap<String, crate::Value>,
2395) -> Result<HashMap<String, crate::Value>> {
2396    let mut out = HashMap::new();
2397    for param in &procedure.params {
2398        let value = match args.remove(&param.name) {
2399            Some(value) => value,
2400            None => param.default.clone().ok_or_else(|| {
2401                MongrelError::InvalidArgument(format!(
2402                    "missing required procedure parameter {:?}",
2403                    param.name
2404                ))
2405            })?,
2406        };
2407        if !param.nullable && matches!(value, crate::Value::Null) {
2408            return Err(MongrelError::InvalidArgument(format!(
2409                "procedure parameter {:?} must not be NULL",
2410                param.name
2411            )));
2412        }
2413        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty) {
2414            return Err(MongrelError::InvalidArgument(format!(
2415                "procedure parameter {:?} has wrong type",
2416                param.name
2417            )));
2418        }
2419        out.insert(param.name.clone(), value);
2420    }
2421    if let Some(extra) = args.keys().next() {
2422        return Err(MongrelError::InvalidArgument(format!(
2423            "unknown procedure parameter {extra:?}"
2424        )));
2425    }
2426    Ok(out)
2427}
2428
2429fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
2430    matches!(
2431        (value, ty),
2432        (crate::Value::Bool(_), crate::TypeId::Bool)
2433            | (crate::Value::Int64(_), crate::TypeId::Int8)
2434            | (crate::Value::Int64(_), crate::TypeId::Int16)
2435            | (crate::Value::Int64(_), crate::TypeId::Int32)
2436            | (crate::Value::Int64(_), crate::TypeId::Int64)
2437            | (crate::Value::Int64(_), crate::TypeId::UInt8)
2438            | (crate::Value::Int64(_), crate::TypeId::UInt16)
2439            | (crate::Value::Int64(_), crate::TypeId::UInt32)
2440            | (crate::Value::Int64(_), crate::TypeId::UInt64)
2441            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
2442            | (crate::Value::Int64(_), crate::TypeId::Date32)
2443            | (crate::Value::Float64(_), crate::TypeId::Float32)
2444            | (crate::Value::Float64(_), crate::TypeId::Float64)
2445            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
2446            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
2447    )
2448}
2449
2450fn eval_cells(
2451    cells: &[crate::procedure::ProcedureCell],
2452    args: &HashMap<String, crate::Value>,
2453    outputs: &HashMap<String, ProcedureCallOutput>,
2454) -> Result<Vec<(u16, crate::Value)>> {
2455    cells
2456        .iter()
2457        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
2458        .collect()
2459}
2460
2461fn eval_condition(
2462    condition: &ProcedureCondition,
2463    args: &HashMap<String, crate::Value>,
2464    outputs: &HashMap<String, ProcedureCallOutput>,
2465) -> Result<crate::Condition> {
2466    Ok(match condition {
2467        ProcedureCondition::Pk { value } => {
2468            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
2469        }
2470        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
2471            column_id: *column_id,
2472            value: eval_value(value, args, outputs)?.encode_key(),
2473        },
2474        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
2475            column_id: *column_id,
2476            values: values
2477                .iter()
2478                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
2479                .collect::<Result<Vec<_>>>()?,
2480        },
2481        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
2482            column_id: *column_id,
2483            lo: expect_i64(eval_value(lo, args, outputs)?)?,
2484            hi: expect_i64(eval_value(hi, args, outputs)?)?,
2485        },
2486        ProcedureCondition::RangeF64 {
2487            column_id,
2488            lo,
2489            lo_inclusive,
2490            hi,
2491            hi_inclusive,
2492        } => crate::Condition::RangeF64 {
2493            column_id: *column_id,
2494            lo: expect_f64(eval_value(lo, args, outputs)?)?,
2495            lo_inclusive: *lo_inclusive,
2496            hi: expect_f64(eval_value(hi, args, outputs)?)?,
2497            hi_inclusive: *hi_inclusive,
2498        },
2499        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
2500            column_id: *column_id,
2501        },
2502        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
2503            column_id: *column_id,
2504        },
2505        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
2506            column_id: *column_id,
2507            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
2508        },
2509    })
2510}
2511
2512fn eval_value(
2513    value: &ProcedureValue,
2514    args: &HashMap<String, crate::Value>,
2515    outputs: &HashMap<String, ProcedureCallOutput>,
2516) -> Result<crate::Value> {
2517    match value {
2518        ProcedureValue::Literal(value) => Ok(value.clone()),
2519        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
2520            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
2521        }),
2522        ProcedureValue::StepScalar(id) => match outputs.get(id) {
2523            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
2524            _ => Err(MongrelError::InvalidArgument(format!(
2525                "procedure step {id:?} did not return a scalar"
2526            ))),
2527        },
2528        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
2529            Err(MongrelError::InvalidArgument(
2530                "row-valued procedure reference cannot be used as a scalar".into(),
2531            ))
2532        }
2533        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
2534            "structured procedure value cannot be used as a scalar cell".into(),
2535        )),
2536    }
2537}
2538
2539fn eval_return_output(
2540    value: &ProcedureValue,
2541    args: &HashMap<String, crate::Value>,
2542    outputs: &HashMap<String, ProcedureCallOutput>,
2543) -> Result<ProcedureCallOutput> {
2544    match value {
2545        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
2546        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
2547            args.get(name).cloned().ok_or_else(|| {
2548                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
2549            })?,
2550        )),
2551        ProcedureValue::StepRows(id)
2552        | ProcedureValue::StepRow(id)
2553        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
2554            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
2555        }),
2556        ProcedureValue::Object(fields) => {
2557            let mut out = Vec::with_capacity(fields.len());
2558            for (name, value) in fields {
2559                out.push((name.clone(), eval_return_output(value, args, outputs)?));
2560            }
2561            Ok(ProcedureCallOutput::Object(out))
2562        }
2563        ProcedureValue::Array(values) => {
2564            let mut out = Vec::with_capacity(values.len());
2565            for value in values {
2566                out.push(eval_return_output(value, args, outputs)?);
2567            }
2568            Ok(ProcedureCallOutput::Array(out))
2569        }
2570    }
2571}
2572
2573fn expect_i64(value: crate::Value) -> Result<i64> {
2574    match value {
2575        crate::Value::Int64(value) => Ok(value),
2576        _ => Err(MongrelError::InvalidArgument(
2577            "procedure value must be Int64".into(),
2578        )),
2579    }
2580}
2581
2582fn expect_f64(value: crate::Value) -> Result<f64> {
2583    match value {
2584        crate::Value::Float64(value) => Ok(value),
2585        _ => Err(MongrelError::InvalidArgument(
2586            "procedure value must be Float64".into(),
2587        )),
2588    }
2589}
2590
2591fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
2592    match value {
2593        crate::Value::Bytes(value) => Ok(value),
2594        _ => Err(MongrelError::InvalidArgument(
2595            "procedure value must be Bytes".into(),
2596        )),
2597    }
2598}
2599
2600fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
2601    if schema.columns.iter().any(|c| c.id == column_id) {
2602        Ok(())
2603    } else {
2604        Err(MongrelError::InvalidArgument(format!(
2605            "unknown column id {column_id}"
2606        )))
2607    }
2608}
2609
2610/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
2611/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
2612/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
2613/// catalog. This pass closes that window by reconstructing missing entries
2614/// (and marking committed drops) before tables are mounted.
2615fn recover_ddl_from_wal(
2616    root: &Path,
2617    cat: &mut Catalog,
2618    meta_dek: Option<&[u8; META_DEK_LEN]>,
2619    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
2620) -> Result<()> {
2621    use crate::wal::{DdlOp, Op, SharedWal};
2622
2623    let records = match SharedWal::replay_with_dek(root, wal_dek) {
2624        Ok(r) => r,
2625        Err(_) => return Ok(()),
2626    };
2627
2628    let mut committed: HashMap<u64, u64> = HashMap::new();
2629    for r in &records {
2630        if let Op::TxnCommit { epoch: ce, .. } = r.op {
2631            committed.insert(r.txn_id, ce);
2632        }
2633    }
2634
2635    let mut changed = false;
2636    for r in records {
2637        let Some(&ce) = committed.get(&r.txn_id) else {
2638            continue;
2639        };
2640        match r.op {
2641            Op::Ddl(DdlOp::CreateTable {
2642                table_id,
2643                ref name,
2644                ref schema_json,
2645            }) => {
2646                if cat.tables.iter().any(|t| t.table_id == table_id) {
2647                    continue;
2648                }
2649                let schema = DdlOp::decode_schema(schema_json)?;
2650                let tdir = root.join(TABLES_DIR).join(table_id.to_string());
2651                if !tdir.exists() {
2652                    std::fs::create_dir_all(tdir.join(crate::engine::WAL_DIR))?;
2653                    std::fs::create_dir_all(tdir.join(crate::engine::RUNS_DIR))?;
2654                    crate::engine::write_schema(&tdir, &schema)?;
2655                    // The DB-wide meta DEK is also the per-table manifest meta
2656                    // DEK (both derive from the KEK via `derive_meta_key`), so a
2657                    // reconstructed manifest must be sealed with it — otherwise
2658                    // the follow-up `Table::open_in` cannot authenticate it on an
2659                    // encrypted DB and the table becomes permanently unopenable.
2660                    let mut m = crate::manifest::Manifest::new(table_id, schema.schema_id);
2661                    crate::manifest::write_atomic(&tdir, &mut m, meta_dek)?;
2662                }
2663                cat.tables.push(CatalogEntry {
2664                    table_id,
2665                    name: name.clone(),
2666                    schema,
2667                    state: TableState::Live,
2668                    created_epoch: ce,
2669                });
2670                cat.next_table_id = cat.next_table_id.max(table_id + 1);
2671                changed = true;
2672            }
2673            Op::Ddl(DdlOp::DropTable { table_id }) => {
2674                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
2675                    if matches!(entry.state, TableState::Live) {
2676                        entry.state = TableState::Dropped { at_epoch: ce };
2677                        changed = true;
2678                    }
2679                }
2680            }
2681            Op::Ddl(DdlOp::RenameTable {
2682                table_id,
2683                ref new_name,
2684            }) => {
2685                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
2686                    if entry.name != *new_name {
2687                        entry.name = new_name.clone();
2688                        changed = true;
2689                    }
2690                }
2691                // If the entry is absent, its CreateTable was already
2692                // checkpointed carrying the post-rename name, so there is
2693                // nothing to apply — a no-op, not an error.
2694            }
2695            Op::Ddl(DdlOp::AlterTable {
2696                table_id,
2697                ref column_json,
2698            }) => {
2699                let column = DdlOp::decode_column(column_json)?;
2700                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
2701                    if apply_recovered_column_def(&mut entry.schema, column) {
2702                        let tdir = root.join(TABLES_DIR).join(table_id.to_string());
2703                        if tdir.exists() {
2704                            crate::engine::write_schema(&tdir, &entry.schema)?;
2705                        }
2706                        changed = true;
2707                    }
2708                }
2709            }
2710            _ => {}
2711        }
2712    }
2713
2714    if changed {
2715        catalog::write_atomic(root, cat, meta_dek)?;
2716    }
2717    Ok(())
2718}
2719
2720fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> bool {
2721    match schema.columns.iter_mut().find(|c| c.id == column.id) {
2722        Some(existing) if *existing == column => false,
2723        Some(existing) => {
2724            *existing = column;
2725            schema.schema_id = schema.schema_id.saturating_add(1);
2726            true
2727        }
2728        None => {
2729            schema.columns.push(column);
2730            schema.schema_id = schema.schema_id.saturating_add(1);
2731            true
2732        }
2733    }
2734}
2735
2736/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
2737/// #14). These dirs hold pending uniform-epoch runs from large transactions
2738/// that were aborted or crashed before commit. On open, all such dirs are safe
2739/// to remove — committed txns moved their runs to `_runs/` at publish time.
2740fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
2741    for entry in &cat.tables {
2742        let txn_dir = root
2743            .join(TABLES_DIR)
2744            .join(entry.table_id.to_string())
2745            .join("_txn");
2746        if txn_dir.exists() {
2747            let _ = std::fs::remove_dir_all(&txn_dir);
2748        }
2749    }
2750}