Skip to main content

hayson_engine/database/
layout.rs

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