Skip to main content

hayson_engine/database/
layout.rs

1use std::path::{Path, PathBuf};
2
3use serde_json::Value;
4
5use crate::catalog::CatalogLayout;
6use crate::errors::HaysonSchemaError;
7use crate::lock::LOCK_FILE;
8use crate::txn::JOURNAL_FILE;
9
10/// Name of the metadata document a database keeps in its root when `open()` names none — the port
11/// of the TS `DEFAULT_MANIFEST_FILE` (`packages/hayson/src/database/layout.ts`).
12pub const DEFAULT_METADATA_FILE: &str = "hayson.json";
13pub const LOG_SUFFIX: &str = ".jsonl";
14pub const INDEX_SUFFIX: &str = ".idx";
15/// Suffix of the per-table schema files only format 1 keeps on disk. Nothing here writes one —
16/// `migrate_layout` carries them across, which is the whole of this engine's dealings with them.
17pub const SCHEMA_SUFFIX: &str = ".schema.json";
18
19const NESTED_TABLES_DIR: &str = "tables";
20const NESTED_INDEX_DIR: &str = "index";
21const NESTED_RUNTIME_DIR: &str = "runtime";
22
23/// How a database directory is arranged on disk — the port of the TS `LayoutKind`.
24///
25/// `Flat` keeps every file side by side in the database directory. `Nested` keeps the metadata
26/// document at the root and sorts the rest into subdirectories by role: logs under `tables/`,
27/// index snapshots under `index/`, the writer lock and the unit-of-work journal under `runtime/`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum LayoutKind {
30    #[default]
31    Flat,
32    Nested,
33}
34
35impl LayoutKind {
36    pub fn as_str(self) -> &'static str {
37        match self {
38            LayoutKind::Flat => "flat",
39            LayoutKind::Nested => "nested",
40        }
41    }
42}
43
44impl From<LayoutKind> for CatalogLayout {
45    fn from(kind: LayoutKind) -> Self {
46        match kind {
47            LayoutKind::Flat => CatalogLayout::Flat,
48            LayoutKind::Nested => CatalogLayout::Nested,
49        }
50    }
51}
52
53impl From<CatalogLayout> for LayoutKind {
54    fn from(layout: CatalogLayout) -> Self {
55        match layout {
56            CatalogLayout::Flat => LayoutKind::Flat,
57            CatalogLayout::Nested => LayoutKind::Nested,
58        }
59    }
60}
61
62/// The `layout`/`metadataFile`/`manifest` triple of `OpenOptions`, exactly as a caller passed it —
63/// unresolved, because which of the two file names wins (and whether they even agree) is decided
64/// by [`LayoutOptions::metadata_file`], and the kind by [`resolve_kind`].
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct LayoutOptions {
67    /// `None` means "whatever is already on disk", not `Flat` — see [`resolve_kind`].
68    pub kind: Option<LayoutKind>,
69    /// Name of the metadata document inside the database directory, [`DEFAULT_METADATA_FILE`] when
70    /// absent.
71    pub metadata_file: Option<String>,
72    /// Deprecated alias of `metadata_file`, taking the same value. Both may be given only when they
73    /// name the same file.
74    pub manifest: Option<String>,
75}
76
77impl LayoutOptions {
78    /// The metadata document name these options ask for, or `None` for the default.
79    ///
80    /// Refuses `metadata_file` and `manifest` naming two different files: they name the same
81    /// document, so one of the two would be silently ignored.
82    pub fn metadata_file(&self) -> Result<Option<&str>, HaysonSchemaError> {
83        match (self.metadata_file.as_deref(), self.manifest.as_deref()) {
84            (Some(current), Some(legacy)) if current != legacy => Err(HaysonSchemaError(format!(
85                "\"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"
86            ))),
87            (Some(current), _) => Ok(Some(current)),
88            (None, legacy) => Ok(legacy),
89        }
90    }
91}
92
93/// The layout kind a database is opened in: whatever the metadata document on disk says, or —
94/// when there is none yet — whatever the caller asked for, defaulting to `Flat`.
95///
96/// Refuses reopening a stored database under the other kind: nothing here moves files between the
97/// two arrangements, so honouring the request would mean opening an empty database on top of one
98/// that is not.
99pub fn resolve_kind(
100    root: &Path,
101    stored: Option<LayoutKind>,
102    requested: Option<LayoutKind>,
103) -> Result<LayoutKind, HaysonSchemaError> {
104    let Some(stored) = stored else {
105        return Ok(requested.unwrap_or_default());
106    };
107    match requested {
108        Some(requested) if requested != stored => Err(HaysonSchemaError(format!(
109            "database \"{}\" is stored in the \"{}\" layout but was opened as \"{}\" — move it instead of reopening it differently",
110            root.display(),
111            stored.as_str(),
112            requested.as_str()
113        ))),
114        _ => Ok(stored),
115    }
116}
117
118/// The layout kind recorded in the metadata document at `path`, or `None` when there is no
119/// readable document there.
120///
121/// Deliberately lenient: this runs before the writer lock is taken, only to learn where the lock
122/// itself lives, so an unreadable or malformed document must not fail here — it is read again, for
123/// real, once the directory is locked, and that read is what reports the problem.
124pub fn stored_kind_of(path: &Path) -> Option<LayoutKind> {
125    stored_kind_with(path, None)
126}
127
128/// [`stored_kind_of`] for a catalog that may be sealed: with `sealer` it is opened first; without
129/// one a sealed catalog is as unreadable as a damaged one, and reported as no document.
130pub fn stored_kind_with(path: &Path, sealer: Option<&crate::crypto::Sealer>) -> Option<LayoutKind> {
131    let contents = crate::catalog::read_catalog_text(path, sealer).ok()??;
132    let value: Value = serde_json::from_str(&contents).ok()?;
133    match value.get("layout").and_then(Value::as_str) {
134        Some("nested") => Some(LayoutKind::Nested),
135        // NOTE: a document with no "layout" at all is a flat database — the TS `storedLayoutOf`.
136        _ => Some(LayoutKind::Flat),
137    }
138}
139
140/// Absolute paths of everything a database keeps on disk, by role — the port of the TS `Layout`,
141/// narrowed to the roles this engine actually writes (format 2 keeps no per-table schema files,
142/// and its write queue stages in memory rather than in a `pending/` directory).
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Layout {
145    kind: LayoutKind,
146    root: PathBuf,
147    metadata_file: String,
148    metadata_path: PathBuf,
149    tables: PathBuf,
150    indexes: PathBuf,
151    runtime: PathBuf,
152}
153
154impl Layout {
155    pub fn new(root: &Path, kind: LayoutKind, metadata_file: &str) -> Layout {
156        let nested = kind == LayoutKind::Nested;
157        let under = |name: &str| if nested { root.join(name) } else { root.to_path_buf() };
158        Layout {
159            kind,
160            root: root.to_path_buf(),
161            metadata_file: metadata_file.to_string(),
162            metadata_path: root.join(metadata_file),
163            tables: under(NESTED_TABLES_DIR),
164            indexes: under(NESTED_INDEX_DIR),
165            runtime: under(NESTED_RUNTIME_DIR),
166        }
167    }
168
169    pub fn kind(&self) -> LayoutKind {
170        self.kind
171    }
172
173    pub fn root(&self) -> &Path {
174        &self.root
175    }
176
177    /// Name of the metadata document in [`Layout::root`] — the value `OpenOptions`'
178    /// `metadataFile`/`manifest` takes.
179    pub fn metadata_file(&self) -> &str {
180        &self.metadata_file
181    }
182
183    /// Absolute path of the metadata document: [`Layout::root`] and [`Layout::metadata_file`]
184    /// joined.
185    pub fn metadata_path(&self) -> &Path {
186        &self.metadata_path
187    }
188
189    /// Directory the table logs live in. A partitioned table gets a `<table>/` subdirectory of this
190    /// one; `Table::open` derives that from the log path it is handed, so nothing else here has to.
191    pub fn tables(&self) -> &Path {
192        &self.tables
193    }
194
195    /// Directory the `<table>.idx` snapshots live in.
196    pub fn indexes(&self) -> &Path {
197        &self.indexes
198    }
199
200    /// Directory the writer lock and the unit-of-work journal live in.
201    pub fn runtime(&self) -> &Path {
202        &self.runtime
203    }
204
205    pub fn log_path(&self, table: &str) -> PathBuf {
206        self.tables.join(format!("{table}{LOG_SUFFIX}"))
207    }
208
209    pub fn index_path(&self, table: &str) -> PathBuf {
210        self.indexes.join(format!("{table}{INDEX_SUFFIX}"))
211    }
212
213    pub fn lock_path(&self) -> PathBuf {
214        self.runtime.join(LOCK_FILE)
215    }
216
217    pub fn journal_path(&self) -> PathBuf {
218        self.runtime.join(JOURNAL_FILE)
219    }
220
221    /// Directories a writable open has to create before anything touches them. Under the flat
222    /// layout that is the database directory and nothing else, which is what makes an open with no
223    /// layout options do exactly what it did before layouts existed.
224    pub fn directories(&self) -> Vec<PathBuf> {
225        if self.kind == LayoutKind::Nested {
226            vec![
227                self.root.clone(),
228                self.tables.clone(),
229                self.indexes.clone(),
230                self.runtime.clone(),
231            ]
232        } else {
233            vec![self.root.clone()]
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use std::path::PathBuf;
241
242    use super::*;
243
244    fn root() -> PathBuf {
245        PathBuf::from("/db")
246    }
247
248    #[test]
249    fn the_flat_layout_keeps_every_role_in_the_database_directory() {
250        let layout = Layout::new(&root(), LayoutKind::Flat, DEFAULT_METADATA_FILE);
251
252        assert_eq!(layout.metadata_path(), Path::new("/db/hayson.json"));
253        assert_eq!(layout.log_path("users"), PathBuf::from("/db/users.jsonl"));
254        assert_eq!(layout.index_path("users"), PathBuf::from("/db/users.idx"));
255        assert_eq!(layout.lock_path(), PathBuf::from("/db").join(LOCK_FILE));
256        assert_eq!(layout.journal_path(), PathBuf::from("/db").join(JOURNAL_FILE));
257        assert_eq!(layout.directories(), vec![root()]);
258    }
259
260    #[test]
261    fn the_nested_layout_sorts_the_roles_into_subdirectories_but_keeps_the_metadata_at_the_root() {
262        let layout = Layout::new(&root(), LayoutKind::Nested, DEFAULT_METADATA_FILE);
263
264        assert_eq!(layout.metadata_path(), Path::new("/db/hayson.json"));
265        assert_eq!(layout.log_path("users"), PathBuf::from("/db/tables/users.jsonl"));
266        assert_eq!(layout.index_path("users"), PathBuf::from("/db/index/users.idx"));
267        assert_eq!(layout.lock_path(), PathBuf::from("/db/runtime").join(LOCK_FILE));
268        assert_eq!(layout.journal_path(), PathBuf::from("/db/runtime").join(JOURNAL_FILE));
269        assert_eq!(
270            layout.directories(),
271            vec![
272                root(),
273                PathBuf::from("/db/tables"),
274                PathBuf::from("/db/index"),
275                PathBuf::from("/db/runtime"),
276            ]
277        );
278    }
279
280    #[test]
281    fn a_custom_metadata_file_moves_only_that_document() {
282        let layout = Layout::new(&root(), LayoutKind::Nested, "cerebro.json");
283
284        assert_eq!(layout.metadata_file(), "cerebro.json");
285        assert_eq!(layout.metadata_path(), Path::new("/db/cerebro.json"));
286        assert_eq!(layout.log_path("users"), PathBuf::from("/db/tables/users.jsonl"));
287    }
288
289    #[test]
290    fn the_deprecated_alias_names_the_document_on_its_own_and_alongside_an_agreeing_new_name() {
291        let legacy = LayoutOptions {
292            manifest: Some("cerebro.json".into()),
293            ..Default::default()
294        };
295        assert_eq!(legacy.metadata_file().unwrap(), Some("cerebro.json"));
296
297        let both = LayoutOptions {
298            metadata_file: Some("cerebro.json".into()),
299            manifest: Some("cerebro.json".into()),
300            ..Default::default()
301        };
302        assert_eq!(both.metadata_file().unwrap(), Some("cerebro.json"));
303
304        assert_eq!(LayoutOptions::default().metadata_file().unwrap(), None);
305    }
306
307    #[test]
308    fn two_disagreeing_names_for_the_metadata_document_are_refused_naming_both() {
309        let options = LayoutOptions {
310            metadata_file: Some("cerebro.json".into()),
311            manifest: Some("hayson.json".into()),
312            ..Default::default()
313        };
314
315        let error = options.metadata_file().unwrap_err();
316
317        assert!(error.0.contains("\"metadataFile\""), "{}", error.0);
318        assert!(error.0.contains("\"manifest\""), "{}", error.0);
319        assert!(
320            error.0.contains("cerebro.json") && error.0.contains("hayson.json"),
321            "{}",
322            error.0
323        );
324    }
325
326    #[test]
327    fn the_kind_on_disk_wins_over_an_absent_request_and_the_request_wins_over_nothing_on_disk() {
328        assert_eq!(resolve_kind(&root(), None, None).unwrap(), LayoutKind::Flat);
329        assert_eq!(
330            resolve_kind(&root(), None, Some(LayoutKind::Nested)).unwrap(),
331            LayoutKind::Nested
332        );
333        assert_eq!(
334            resolve_kind(&root(), Some(LayoutKind::Nested), None).unwrap(),
335            LayoutKind::Nested
336        );
337        assert_eq!(
338            resolve_kind(&root(), Some(LayoutKind::Nested), Some(LayoutKind::Nested)).unwrap(),
339            LayoutKind::Nested
340        );
341    }
342
343    #[test]
344    fn reopening_a_stored_database_under_the_other_kind_is_refused_naming_both() {
345        let error = resolve_kind(&root(), Some(LayoutKind::Nested), Some(LayoutKind::Flat)).unwrap_err();
346
347        assert!(
348            error.0.contains("\"nested\"") && error.0.contains("\"flat\""),
349            "{}",
350            error.0
351        );
352    }
353}