Skip to main content

mongreldb_core/
catalog.rs

1//! DB-wide catalog checkpoint (spec ยง5.1).
2//!
3//! The catalog records every table's id, name, schema, and live/dropped state
4//! plus the DB-wide monotonic counters (`db_epoch`, `next_table_id`,
5//! `open_generation` (sidecar), `next_segment_no`). CATALOG is rewritten on
6//! DDL and persisted to `<root>/CATALOG` with:
7//!
8//! - a fixed magic + SHA-256 integrity tag for plaintext, or
9//! - AES-256-GCM (`meta_dek`-derived via [`Kek::derive_meta_key`]) which both
10//!   encrypts and authenticates, plus
11//! - a directory `sync_all` after the atomic rename (review fix #19), so a crash
12//!   never leaves a half-linked catalog entry.
13
14use crate::error::{MongrelError, Result};
15use crate::external_table::ExternalTableEntry;
16use crate::procedure::ProcedureEntry;
17use crate::schema::Schema;
18use crate::trigger::TriggerEntry;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use std::io::Read;
22use std::path::Path;
23
24pub const CATALOG_FILENAME: &str = "CATALOG";
25const MAGIC: &[u8; 8] = b"MONGRCAT";
26const CATALOG_FORMAT_VERSION: u16 = 1;
27/// 32-byte meta DEK length (matches [`crate::encryption::DEK_LEN`]).
28pub const META_DEK_LEN: usize = 32;
29
30/// Lifecycle state of a catalog table entry.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub enum TableState {
33    /// Live and queryable.
34    Live,
35    /// Logically dropped at `at_epoch`; the physical subdir is reaped once no
36    /// reader pins a snapshot older than `at_epoch`.
37    Dropped { at_epoch: u64 },
38    /// Hidden CTAS build state. It is mounted only by the creating handle and
39    /// becomes queryable through one durable publish operation.
40    Building {
41        intended_name: String,
42        query_id: String,
43        created_at_unix_nanos: u64,
44        #[serde(default)]
45        replaces_table_id: Option<u64>,
46    },
47}
48
49/// One row of the catalog.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct CatalogEntry {
53    pub table_id: u64,
54    pub name: String,
55    pub schema: Schema,
56    pub state: TableState,
57    pub created_epoch: u64,
58}
59
60/// Persistent definition for a physical materialized-view table.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(deny_unknown_fields)]
63pub struct MaterializedViewEntry {
64    pub name: String,
65    pub query: String,
66    pub last_refresh_epoch: u64,
67    #[serde(default)]
68    pub incremental: Option<IncrementalAggregateView>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(deny_unknown_fields)]
73pub struct IncrementalAggregateView {
74    pub source_table: String,
75    pub source_table_id: u64,
76    pub group_column: u16,
77    pub group_output_column: u16,
78    pub outputs: Vec<IncrementalAggregateOutput>,
79    pub count_output_column: u16,
80    pub checkpoint_event_id: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(deny_unknown_fields)]
85pub struct IncrementalAggregateOutput {
86    pub output_column: u16,
87    pub kind: IncrementalAggregateKind,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub enum IncrementalAggregateKind {
92    Count,
93    Sum { source_column: u16 },
94}
95
96/// The full in-memory catalog, mirrored on disk by [`write_atomic`].
97///
98/// Note: `open_generation` is intentionally **not** stored here โ€” it bumps on
99/// every open, and keeping it in CATALOG would dirty the working tree even for
100/// a bare read. It lives in a separate sidecar file (`_meta/generation`) that
101/// callers can `.gitignore` for content-addressed storage workflows.
102#[derive(Debug, Clone, Serialize, Default)]
103pub struct Catalog {
104    /// Highest epoch ever assigned by this DB's commit sequencer.
105    pub db_epoch: u64,
106    /// Next table id to allocate.
107    pub next_table_id: u64,
108    /// Next shared-WAL segment number to allocate.
109    pub next_segment_no: u64,
110    pub tables: Vec<CatalogEntry>,
111    #[serde(default)]
112    pub procedures: Vec<ProcedureEntry>,
113    #[serde(default)]
114    pub triggers: Vec<TriggerEntry>,
115    #[serde(default)]
116    pub external_tables: Vec<ExternalTableEntry>,
117    #[serde(default)]
118    pub materialized_views: Vec<MaterializedViewEntry>,
119    #[serde(default)]
120    pub security: crate::security::SecurityCatalog,
121    /// Monotonic version for optimistic authorization snapshots.
122    #[serde(default)]
123    pub security_version: u64,
124    /// Catalog-level user accounts (Argon2id-hashed credentials).
125    #[serde(default)]
126    pub users: Vec<crate::auth::UserEntry>,
127    /// Catalog-level role definitions.
128    #[serde(default)]
129    pub roles: Vec<crate::auth::RoleEntry>,
130    /// Next monotonic user id to allocate.
131    #[serde(default)]
132    pub next_user_id: u64,
133    /// When true, every Database/Table/Transaction/MongrelSession operation
134    /// requires an authenticated `Principal` with sufficient permission.
135    /// Defaults to false โ†’ existing credentialless databases open unchanged.
136    /// See `docs/15-credential-enforcement.md`.
137    #[serde(default)]
138    pub require_auth: bool,
139    /// SQLite-compatible application metadata used by the SQL layer.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub user_version: Option<i64>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub application_id: Option<i64>,
144}
145
146#[derive(Deserialize)]
147#[serde(deny_unknown_fields)]
148struct CatalogWire {
149    db_epoch: u64,
150    next_table_id: u64,
151    next_segment_no: u64,
152    tables: Vec<CatalogEntry>,
153    #[serde(default)]
154    procedures: Vec<ProcedureEntry>,
155    #[serde(default)]
156    triggers: Vec<TriggerEntry>,
157    #[serde(default)]
158    external_tables: Vec<ExternalTableEntry>,
159    #[serde(default)]
160    materialized_views: Vec<MaterializedViewEntry>,
161    #[serde(default)]
162    security: crate::security::SecurityCatalog,
163    #[serde(default)]
164    security_version: u64,
165    #[serde(default)]
166    users: Vec<crate::auth::UserEntry>,
167    #[serde(default)]
168    roles: Vec<crate::auth::RoleEntry>,
169    #[serde(default)]
170    next_user_id: u64,
171    #[serde(default)]
172    require_auth: bool,
173    #[serde(default)]
174    user_version: Option<i64>,
175    #[serde(default)]
176    application_id: Option<i64>,
177    // Known pre-sidecar field. It is intentionally ignored during migration;
178    // every other unknown top-level field remains rejected.
179    #[serde(default)]
180    open_generation: Option<u64>,
181}
182
183impl<'de> Deserialize<'de> for Catalog {
184    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
185    where
186        D: serde::Deserializer<'de>,
187    {
188        let wire = CatalogWire::deserialize(deserializer)?;
189        let _ = wire.open_generation;
190        Ok(Self {
191            db_epoch: wire.db_epoch,
192            next_table_id: wire.next_table_id,
193            next_segment_no: wire.next_segment_no,
194            tables: wire.tables,
195            procedures: wire.procedures,
196            triggers: wire.triggers,
197            external_tables: wire.external_tables,
198            materialized_views: wire.materialized_views,
199            security: wire.security,
200            security_version: wire.security_version,
201            users: wire.users,
202            roles: wire.roles,
203            next_user_id: wire.next_user_id,
204            require_auth: wire.require_auth,
205            user_version: wire.user_version,
206            application_id: wire.application_id,
207        })
208    }
209}
210
211#[derive(Serialize, Deserialize)]
212#[serde(deny_unknown_fields)]
213struct CatalogEnvelope {
214    format_version: u16,
215    catalog: Catalog,
216}
217
218/// The `open_generation` counter, stored in `_meta/generation` (NOT in CATALOG).
219/// Bumped on every open to scope `txn_id` across reopens so ids never alias.
220///
221/// Kept as a sidecar so that CATALOG is stable across bare opens โ€” the only
222/// volatile bytes in the database directory during a read-only session are
223/// this 8-byte file + `.lock` + caches, all of which can be `.gitignore`-d.
224pub const GENERATION_FILENAME: &str = "generation";
225
226/// Read `open_generation` from `_meta/generation`. Missing is reported
227/// separately for one-time migration; malformed or unreadable state fails
228/// closed because resetting this counter can alias retained WAL transactions.
229pub fn read_generation(root: &crate::durable_file::DurableRoot) -> Result<Option<u64>> {
230    let relative = Path::new("_meta").join(GENERATION_FILENAME);
231    match root.entry_exists(&relative) {
232        Ok(true) => {}
233        Ok(false) => return Ok(None),
234        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
235        Err(error) => return Err(error.into()),
236    }
237    let mut file = root.open_regular(&relative)?;
238    let length = file.metadata()?.len();
239    if length != 8 {
240        return Err(MongrelError::Other(format!(
241            "invalid open-generation length: got {length}, expected 8"
242        )));
243    }
244    let mut bytes = [0_u8; 8];
245    std::io::Read::read_exact(&mut file, &mut bytes)?;
246    Ok(Some(u64::from_le_bytes(bytes)))
247}
248
249/// Write `open_generation` to `_meta/generation` atomically (temp + rename +
250/// fsync). This is intentionally a separate file from CATALOG so that CATALOG
251/// stays byte-stable across bare opens (the generation counter is the only
252/// field that changes on every open).
253pub fn write_generation(root: &crate::durable_file::DurableRoot, generation: u64) -> Result<()> {
254    root.create_directory_all("_meta")?;
255    root.write_atomic(
256        Path::new("_meta").join(GENERATION_FILENAME),
257        &generation.to_le_bytes(),
258    )?;
259    Ok(())
260}
261
262impl Catalog {
263    /// An empty catalog for a freshly created DB.
264    pub fn empty() -> Self {
265        Catalog::default()
266    }
267
268    /// Look up an entry by name (live only).
269    pub fn live(&self, name: &str) -> Option<&CatalogEntry> {
270        self.tables
271            .iter()
272            .find(|t| t.name == name && matches!(t.state, TableState::Live))
273    }
274
275    pub(crate) fn building(&self, name: &str) -> Option<&CatalogEntry> {
276        self.tables
277            .iter()
278            .find(|table| table.name == name && matches!(table.state, TableState::Building { .. }))
279    }
280
281    pub(crate) fn building_for(&self, intended_name: &str) -> Option<&CatalogEntry> {
282        self.tables.iter().find(|table| {
283            matches!(
284                &table.state,
285                TableState::Building {
286                    intended_name: candidate,
287                    ..
288                } if candidate == intended_name
289            )
290        })
291    }
292}
293
294#[cfg(feature = "encryption")]
295fn seal(body: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>> {
296    match meta_dek {
297        Some(dek) => crate::encryption::encrypt_blob(dek, body),
298        None => Ok(plaintext_frame(body)),
299    }
300}
301
302#[cfg(not(feature = "encryption"))]
303fn seal(body: &[u8], _meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Vec<u8>> {
304    Ok(plaintext_frame(body))
305}
306
307fn plaintext_frame(body: &[u8]) -> Vec<u8> {
308    let hash = Sha256::digest(body);
309    let mut out = Vec::with_capacity(body.len() + 8 + 32);
310    out.extend_from_slice(MAGIC);
311    out.extend_from_slice(&hash);
312    out.extend_from_slice(body);
313    out
314}
315
316/// Atomically write the catalog to `<dir>/CATALOG` (review fix #19: dir-fsync).
317///
318/// If `meta_dek` is `Some`, the body is AES-256-GCM sealed (confidential +
319/// authenticated); otherwise the body carries a SHA-256 tag (integrity only).
320pub fn write_atomic(
321    dir: &Path,
322    cat: &Catalog,
323    meta_dek: Option<&[u8; META_DEK_LEN]>,
324) -> Result<()> {
325    write_atomic_controlled(dir, cat, meta_dek, || Ok(()))
326}
327
328/// Prepare and fsync a complete catalog replacement, then invoke
329/// `before_publish` immediately before the atomic rename makes it durable.
330pub fn write_atomic_controlled<F>(
331    dir: &Path,
332    cat: &Catalog,
333    meta_dek: Option<&[u8; META_DEK_LEN]>,
334    before_publish: F,
335) -> Result<()>
336where
337    F: FnOnce() -> Result<()>,
338{
339    write_atomic_controlled_with_after(dir, cat, meta_dek, before_publish, || {})
340}
341
342/// Controlled catalog replacement with a live-publication callback. The
343/// callback runs after rename, before the parent directory is fsynced. Thus a
344/// later error means the replacement is visible in this process but its
345/// crash-durable outcome is unknown.
346pub(crate) fn write_atomic_controlled_with_after<F, A>(
347    dir: &Path,
348    cat: &Catalog,
349    meta_dek: Option<&[u8; META_DEK_LEN]>,
350    before_publish: F,
351    after_publish: A,
352) -> Result<()>
353where
354    F: FnOnce() -> Result<()>,
355    A: FnOnce(),
356{
357    let body = encode(cat)?;
358    let payload = seal(&body, meta_dek)?;
359
360    let root = crate::durable_file::DurableRoot::open(dir)?;
361    root.write_atomic_controlled_with_after(
362        CATALOG_FILENAME,
363        &payload,
364        before_publish,
365        after_publish,
366    )?;
367    Ok(())
368}
369
370#[cfg(feature = "encryption")]
371fn open_payload(bytes: &[u8], meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Option<Catalog>> {
372    match meta_dek {
373        Some(dek) => match crate::encryption::decrypt_blob(dek, bytes) {
374            Ok(body) => deserialize(&body),
375            Err(_) => Ok(None),
376        },
377        None => parse_plaintext(bytes),
378    }
379}
380
381#[cfg(not(feature = "encryption"))]
382fn open_payload(bytes: &[u8], _meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Option<Catalog>> {
383    parse_plaintext(bytes)
384}
385
386pub(crate) fn encode(catalog: &Catalog) -> Result<Vec<u8>> {
387    serde_json::to_vec(&CatalogEnvelope {
388        format_version: CATALOG_FORMAT_VERSION,
389        catalog: catalog.clone(),
390    })
391    .map_err(|error| MongrelError::Other(format!("catalog serialize: {error}")))
392}
393
394pub(crate) fn write_durable(
395    root: &crate::durable_file::DurableRoot,
396    catalog: &Catalog,
397    meta_dek: Option<&[u8; META_DEK_LEN]>,
398) -> Result<()> {
399    let body = encode(catalog)?;
400    let payload = seal(&body, meta_dek)?;
401    root.write_atomic(CATALOG_FILENAME, &payload)?;
402    Ok(())
403}
404
405pub(crate) fn decode(body: &[u8]) -> Result<Catalog> {
406    let value: serde_json::Value = serde_json::from_slice(body)
407        .map_err(|error| MongrelError::Other(format!("catalog deserialize: {error}")))?;
408    let is_envelope = value.as_object().is_some_and(|object| {
409        object.contains_key("format_version") || object.contains_key("catalog")
410    });
411    if !is_envelope {
412        // Legacy pre-envelope catalogs remain readable, but unknown fields are
413        // rejected by `Catalog::deny_unknown_fields`.
414        return serde_json::from_value(value)
415            .map_err(|error| MongrelError::Other(format!("catalog deserialize: {error}")));
416    }
417    let envelope: CatalogEnvelope = serde_json::from_value(value)
418        .map_err(|error| MongrelError::Other(format!("catalog deserialize: {error}")))?;
419    if envelope.format_version != CATALOG_FORMAT_VERSION {
420        return Err(MongrelError::Other(format!(
421            "unsupported catalog format version {}",
422            envelope.format_version
423        )));
424    }
425    Ok(envelope.catalog)
426}
427
428fn deserialize(body: &[u8]) -> Result<Option<Catalog>> {
429    decode(body).map(Some)
430}
431
432/// Read the catalog from `<dir>/CATALOG`. Returns `Ok(None)` if no catalog is
433/// present, or if authentication fails (tampered / wrong key).
434pub fn read(dir: &Path, meta_dek: Option<&[u8; META_DEK_LEN]>) -> Result<Option<Catalog>> {
435    let p = dir.join(CATALOG_FILENAME);
436    let file = match crate::durable_file::open_regular_nofollow(&p) {
437        Ok(file) => file,
438        Err(MongrelError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
439        Err(e) => return Err(e),
440    };
441    read_file(file, meta_dek)
442}
443
444pub(crate) fn read_durable(
445    root: &crate::durable_file::DurableRoot,
446    meta_dek: Option<&[u8; META_DEK_LEN]>,
447) -> Result<Option<Catalog>> {
448    let file = match root.open_regular(CATALOG_FILENAME) {
449        Ok(file) => file,
450        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
451        Err(error) => return Err(error.into()),
452    };
453    read_file(file, meta_dek)
454}
455
456fn read_file(
457    file: std::fs::File,
458    meta_dek: Option<&[u8; META_DEK_LEN]>,
459) -> Result<Option<Catalog>> {
460    const MAX_CATALOG_BYTES: u64 = 64 * 1024 * 1024;
461    let length = file.metadata()?.len();
462    if length > MAX_CATALOG_BYTES {
463        return Err(MongrelError::ResourceLimitExceeded {
464            resource: "catalog bytes",
465            requested: usize::try_from(length).unwrap_or(usize::MAX),
466            limit: MAX_CATALOG_BYTES as usize,
467        });
468    }
469    let mut bytes = Vec::with_capacity(length as usize);
470    file.take(MAX_CATALOG_BYTES + 1).read_to_end(&mut bytes)?;
471    if bytes.len() as u64 != length {
472        return Err(MongrelError::Other(
473            "catalog length changed while reading".into(),
474        ));
475    }
476    open_payload(&bytes, meta_dek)
477}
478
479fn parse_plaintext(bytes: &[u8]) -> Result<Option<Catalog>> {
480    if bytes.len() < 8 + 32 || &bytes[..8] != MAGIC {
481        return Ok(None);
482    }
483    let (tag, body) = bytes[8..].split_at(32);
484    let calc = Sha256::digest(body);
485    if tag != calc.as_slice() {
486        // tampered
487        return Ok(None);
488    }
489    deserialize(body)
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn empty_catalog_default() {
498        let c = Catalog::empty();
499        assert_eq!(c.db_epoch, 0);
500        assert_eq!(c.next_table_id, 0);
501        assert!(c.tables.is_empty());
502    }
503}