hayson-engine 0.26.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::catalog::CatalogLayout;
use crate::errors::HaysonSchemaError;
use crate::lock::LOCK_FILE;
use crate::txn::JOURNAL_FILE;

/// Name of the metadata document a database keeps in its root when `open()` names none — the port
/// of the TS `DEFAULT_MANIFEST_FILE` (`packages/hayson/src/database/layout.ts`).
pub const DEFAULT_METADATA_FILE: &str = "hayson.json";
pub const LOG_SUFFIX: &str = ".jsonl";
pub const INDEX_SUFFIX: &str = ".idx";
/// Suffix of the per-table schema files only format 1 keeps on disk. Nothing here writes one —
/// `migrate_layout` carries them across, which is the whole of this engine's dealings with them.
pub const SCHEMA_SUFFIX: &str = ".schema.json";

const NESTED_TABLES_DIR: &str = "tables";
const NESTED_INDEX_DIR: &str = "index";
const NESTED_RUNTIME_DIR: &str = "runtime";

/// How a database directory is arranged on disk — the port of the TS `LayoutKind`.
///
/// `Flat` keeps every file side by side in the database directory. `Nested` keeps the metadata
/// document at the root and sorts the rest into subdirectories by role: logs under `tables/`,
/// index snapshots under `index/`, the writer lock and the unit-of-work journal under `runtime/`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LayoutKind {
    #[default]
    Flat,
    Nested,
}

impl LayoutKind {
    pub fn as_str(self) -> &'static str {
        match self {
            LayoutKind::Flat => "flat",
            LayoutKind::Nested => "nested",
        }
    }
}

impl From<LayoutKind> for CatalogLayout {
    fn from(kind: LayoutKind) -> Self {
        match kind {
            LayoutKind::Flat => CatalogLayout::Flat,
            LayoutKind::Nested => CatalogLayout::Nested,
        }
    }
}

impl From<CatalogLayout> for LayoutKind {
    fn from(layout: CatalogLayout) -> Self {
        match layout {
            CatalogLayout::Flat => LayoutKind::Flat,
            CatalogLayout::Nested => LayoutKind::Nested,
        }
    }
}

/// The `layout`/`metadataFile`/`manifest` triple of `OpenOptions`, exactly as a caller passed it —
/// unresolved, because which of the two file names wins (and whether they even agree) is decided
/// by [`LayoutOptions::metadata_file`], and the kind by [`resolve_kind`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LayoutOptions {
    /// `None` means "whatever is already on disk", not `Flat` — see [`resolve_kind`].
    pub kind: Option<LayoutKind>,
    /// Name of the metadata document inside the database directory, [`DEFAULT_METADATA_FILE`] when
    /// absent.
    pub metadata_file: Option<String>,
    /// Deprecated alias of `metadata_file`, taking the same value. Both may be given only when they
    /// name the same file.
    pub manifest: Option<String>,
}

impl LayoutOptions {
    /// The metadata document name these options ask for, or `None` for the default.
    ///
    /// Refuses `metadata_file` and `manifest` naming two different files: they name the same
    /// document, so one of the two would be silently ignored.
    pub fn metadata_file(&self) -> Result<Option<&str>, HaysonSchemaError> {
        match (self.metadata_file.as_deref(), self.manifest.as_deref()) {
            (Some(current), Some(legacy)) if current != legacy => Err(HaysonSchemaError(format!(
                "\"metadataFile\" is \"{current}\" and \"manifest\" is \"{legacy}\" — \"manifest\" is the deprecated alias of \"metadataFile\" and both name the same file, so one of the two would be silently ignored; pass only one"
            ))),
            (Some(current), _) => Ok(Some(current)),
            (None, legacy) => Ok(legacy),
        }
    }
}

/// The layout kind a database is opened in: whatever the metadata document on disk says, or —
/// when there is none yet — whatever the caller asked for, defaulting to `Flat`.
///
/// Refuses reopening a stored database under the other kind: nothing here moves files between the
/// two arrangements, so honouring the request would mean opening an empty database on top of one
/// that is not.
pub fn resolve_kind(
    root: &Path,
    stored: Option<LayoutKind>,
    requested: Option<LayoutKind>,
) -> Result<LayoutKind, HaysonSchemaError> {
    let Some(stored) = stored else {
        return Ok(requested.unwrap_or_default());
    };
    match requested {
        Some(requested) if requested != stored => Err(HaysonSchemaError(format!(
            "database \"{}\" is stored in the \"{}\" layout but was opened as \"{}\" — move it instead of reopening it differently",
            root.display(),
            stored.as_str(),
            requested.as_str()
        ))),
        _ => Ok(stored),
    }
}

/// The layout kind recorded in the metadata document at `path`, or `None` when there is no
/// readable document there.
///
/// Deliberately lenient: this runs before the writer lock is taken, only to learn where the lock
/// itself lives, so an unreadable or malformed document must not fail here — it is read again, for
/// real, once the directory is locked, and that read is what reports the problem.
pub fn stored_kind_of(path: &Path) -> Option<LayoutKind> {
    stored_kind_with(path, None)
}

/// [`stored_kind_of`] for a catalog that may be sealed: with `sealer` it is opened first; without
/// one a sealed catalog is as unreadable as a damaged one, and reported as no document.
pub fn stored_kind_with(path: &Path, sealer: Option<&crate::crypto::Sealer>) -> Option<LayoutKind> {
    let contents = crate::catalog::read_catalog_text(path, sealer).ok()??;
    let value: Value = serde_json::from_str(&contents).ok()?;
    match value.get("layout").and_then(Value::as_str) {
        Some("nested") => Some(LayoutKind::Nested),
        // NOTE: a document with no "layout" at all is a flat database — the TS `storedLayoutOf`.
        _ => Some(LayoutKind::Flat),
    }
}

/// Absolute paths of everything a database keeps on disk, by role — the port of the TS `Layout`,
/// narrowed to the roles this engine actually writes (format 2 keeps no per-table schema files,
/// and its write queue stages in memory rather than in a `pending/` directory).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Layout {
    kind: LayoutKind,
    root: PathBuf,
    metadata_file: String,
    metadata_path: PathBuf,
    tables: PathBuf,
    indexes: PathBuf,
    runtime: PathBuf,
}

impl Layout {
    pub fn new(root: &Path, kind: LayoutKind, metadata_file: &str) -> Layout {
        let nested = kind == LayoutKind::Nested;
        let under = |name: &str| if nested { root.join(name) } else { root.to_path_buf() };
        Layout {
            kind,
            root: root.to_path_buf(),
            metadata_file: metadata_file.to_string(),
            metadata_path: root.join(metadata_file),
            tables: under(NESTED_TABLES_DIR),
            indexes: under(NESTED_INDEX_DIR),
            runtime: under(NESTED_RUNTIME_DIR),
        }
    }

    pub fn kind(&self) -> LayoutKind {
        self.kind
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Name of the metadata document in [`Layout::root`] — the value `OpenOptions`'
    /// `metadataFile`/`manifest` takes.
    pub fn metadata_file(&self) -> &str {
        &self.metadata_file
    }

    /// Absolute path of the metadata document: [`Layout::root`] and [`Layout::metadata_file`]
    /// joined.
    pub fn metadata_path(&self) -> &Path {
        &self.metadata_path
    }

    /// Directory the table logs live in. A partitioned table gets a `<table>/` subdirectory of this
    /// one; `Table::open` derives that from the log path it is handed, so nothing else here has to.
    pub fn tables(&self) -> &Path {
        &self.tables
    }

    /// Directory the `<table>.idx` snapshots live in.
    pub fn indexes(&self) -> &Path {
        &self.indexes
    }

    /// Directory the writer lock and the unit-of-work journal live in.
    pub fn runtime(&self) -> &Path {
        &self.runtime
    }

    pub fn log_path(&self, table: &str) -> PathBuf {
        self.tables.join(format!("{table}{LOG_SUFFIX}"))
    }

    pub fn index_path(&self, table: &str) -> PathBuf {
        self.indexes.join(format!("{table}{INDEX_SUFFIX}"))
    }

    pub fn lock_path(&self) -> PathBuf {
        self.runtime.join(LOCK_FILE)
    }

    pub fn journal_path(&self) -> PathBuf {
        self.runtime.join(JOURNAL_FILE)
    }

    /// Directories a writable open has to create before anything touches them. Under the flat
    /// layout that is the database directory and nothing else, which is what makes an open with no
    /// layout options do exactly what it did before layouts existed.
    pub fn directories(&self) -> Vec<PathBuf> {
        if self.kind == LayoutKind::Nested {
            vec![
                self.root.clone(),
                self.tables.clone(),
                self.indexes.clone(),
                self.runtime.clone(),
            ]
        } else {
            vec![self.root.clone()]
        }
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;

    fn root() -> PathBuf {
        PathBuf::from("/db")
    }

    #[test]
    fn the_flat_layout_keeps_every_role_in_the_database_directory() {
        let layout = Layout::new(&root(), LayoutKind::Flat, DEFAULT_METADATA_FILE);

        assert_eq!(layout.metadata_path(), Path::new("/db/hayson.json"));
        assert_eq!(layout.log_path("users"), PathBuf::from("/db/users.jsonl"));
        assert_eq!(layout.index_path("users"), PathBuf::from("/db/users.idx"));
        assert_eq!(layout.lock_path(), PathBuf::from("/db").join(LOCK_FILE));
        assert_eq!(layout.journal_path(), PathBuf::from("/db").join(JOURNAL_FILE));
        assert_eq!(layout.directories(), vec![root()]);
    }

    #[test]
    fn the_nested_layout_sorts_the_roles_into_subdirectories_but_keeps_the_metadata_at_the_root() {
        let layout = Layout::new(&root(), LayoutKind::Nested, DEFAULT_METADATA_FILE);

        assert_eq!(layout.metadata_path(), Path::new("/db/hayson.json"));
        assert_eq!(layout.log_path("users"), PathBuf::from("/db/tables/users.jsonl"));
        assert_eq!(layout.index_path("users"), PathBuf::from("/db/index/users.idx"));
        assert_eq!(layout.lock_path(), PathBuf::from("/db/runtime").join(LOCK_FILE));
        assert_eq!(layout.journal_path(), PathBuf::from("/db/runtime").join(JOURNAL_FILE));
        assert_eq!(
            layout.directories(),
            vec![
                root(),
                PathBuf::from("/db/tables"),
                PathBuf::from("/db/index"),
                PathBuf::from("/db/runtime"),
            ]
        );
    }

    #[test]
    fn a_custom_metadata_file_moves_only_that_document() {
        let layout = Layout::new(&root(), LayoutKind::Nested, "cerebro.json");

        assert_eq!(layout.metadata_file(), "cerebro.json");
        assert_eq!(layout.metadata_path(), Path::new("/db/cerebro.json"));
        assert_eq!(layout.log_path("users"), PathBuf::from("/db/tables/users.jsonl"));
    }

    #[test]
    fn the_deprecated_alias_names_the_document_on_its_own_and_alongside_an_agreeing_new_name() {
        let legacy = LayoutOptions {
            manifest: Some("cerebro.json".into()),
            ..Default::default()
        };
        assert_eq!(legacy.metadata_file().unwrap(), Some("cerebro.json"));

        let both = LayoutOptions {
            metadata_file: Some("cerebro.json".into()),
            manifest: Some("cerebro.json".into()),
            ..Default::default()
        };
        assert_eq!(both.metadata_file().unwrap(), Some("cerebro.json"));

        assert_eq!(LayoutOptions::default().metadata_file().unwrap(), None);
    }

    #[test]
    fn two_disagreeing_names_for_the_metadata_document_are_refused_naming_both() {
        let options = LayoutOptions {
            metadata_file: Some("cerebro.json".into()),
            manifest: Some("hayson.json".into()),
            ..Default::default()
        };

        let error = options.metadata_file().unwrap_err();

        assert!(error.0.contains("\"metadataFile\""), "{}", error.0);
        assert!(error.0.contains("\"manifest\""), "{}", error.0);
        assert!(
            error.0.contains("cerebro.json") && error.0.contains("hayson.json"),
            "{}",
            error.0
        );
    }

    #[test]
    fn the_kind_on_disk_wins_over_an_absent_request_and_the_request_wins_over_nothing_on_disk() {
        assert_eq!(resolve_kind(&root(), None, None).unwrap(), LayoutKind::Flat);
        assert_eq!(
            resolve_kind(&root(), None, Some(LayoutKind::Nested)).unwrap(),
            LayoutKind::Nested
        );
        assert_eq!(
            resolve_kind(&root(), Some(LayoutKind::Nested), None).unwrap(),
            LayoutKind::Nested
        );
        assert_eq!(
            resolve_kind(&root(), Some(LayoutKind::Nested), Some(LayoutKind::Nested)).unwrap(),
            LayoutKind::Nested
        );
    }

    #[test]
    fn reopening_a_stored_database_under_the_other_kind_is_refused_naming_both() {
        let error = resolve_kind(&root(), Some(LayoutKind::Nested), Some(LayoutKind::Flat)).unwrap_err();

        assert!(
            error.0.contains("\"nested\"") && error.0.contains("\"flat\""),
            "{}",
            error.0
        );
    }
}