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;
pub const DEFAULT_METADATA_FILE: &str = "hayson.json";
pub const LOG_SUFFIX: &str = ".jsonl";
pub const INDEX_SUFFIX: &str = ".idx";
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";
#[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,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LayoutOptions {
pub kind: Option<LayoutKind>,
pub metadata_file: Option<String>,
pub manifest: Option<String>,
}
impl LayoutOptions {
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),
}
}
}
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),
}
}
pub fn stored_kind_of(path: &Path) -> Option<LayoutKind> {
stored_kind_with(path, None)
}
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),
_ => Some(LayoutKind::Flat),
}
}
#[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
}
pub fn metadata_file(&self) -> &str {
&self.metadata_file
}
pub fn metadata_path(&self) -> &Path {
&self.metadata_path
}
pub fn tables(&self) -> &Path {
&self.tables
}
pub fn indexes(&self) -> &Path {
&self.indexes
}
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)
}
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
);
}
}