Skip to main content

hayson_engine/database/
migrate_layout.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde_json::{Map, Value};
6
7use crate::atomic::write_atomic;
8use crate::catalog::{parse_catalog, write_catalog, Catalog, CatalogLayout};
9use crate::errors::HaysonSchemaError;
10use crate::lock::DirLock;
11
12use super::database::{DatabaseError, HeldLock, LockMode};
13use super::layout::{stored_kind_of, Layout, LayoutKind, DEFAULT_METADATA_FILE, SCHEMA_SUFFIX};
14
15/// The marker a format-1 manifest carries under `hayson`, and a format-2 catalog under
16/// `catalogVersion` (or, in a catalog written before 5.0.0, under `hayson` as well).
17const MANIFEST_MARKER: u64 = 1;
18const CATALOG_MARKER: u64 = 2;
19
20#[cfg(unix)]
21const PERMISSION_BITS: u32 = 0o777;
22
23/// Which arrangement to move a database into, and under what name to leave its metadata document.
24///
25/// Every member is optional, and `Default` is what `migrateLayout(root)` does in TS: move into the
26/// `nested` arrangement, keep the document's name, take the writer lock on the engine's own terms.
27#[derive(Debug, Clone, Default)]
28pub struct LayoutMigrationOptions {
29    /// Arrangement to move into — [`LayoutKind::Nested`] when absent, as in TS.
30    pub to: Option<LayoutKind>,
31    /// Name the metadata document has now, [`DEFAULT_METADATA_FILE`] when absent — the metadata
32    /// catalog in format 2, the manifest in format 1.
33    pub from_metadata_file: Option<String>,
34    /// Deprecated alias of `from_metadata_file`, taking the same value.
35    pub from_manifest: Option<String>,
36    /// Name to give the metadata document as part of the move. Unchanged when absent.
37    pub metadata_file: Option<String>,
38    /// Deprecated alias of `metadata_file`, taking the same value.
39    pub manifest_file: Option<String>,
40    /// Report the moves without touching the disk. A dry run takes no writer lock: it changes
41    /// nothing, so refusing it because somebody else is writing would only hide the plan.
42    pub dry_run: bool,
43    /// How the writer lock is taken for the length of the move. [`LockMode::Skip`] is as dangerous
44    /// here as it is in `OpenOptions`, and for the same reason: the lock is the only thing keeping
45    /// this move and another process's writes off the same files.
46    pub lock: LockMode,
47}
48
49/// One rename the move makes, as it will be applied.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct LayoutMove {
52    pub from: PathBuf,
53    pub to: PathBuf,
54}
55
56/// What [`migrate_layout`] moved, or would move under `dry_run`.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct LayoutMigrationResult {
59    pub root: PathBuf,
60    pub from: LayoutKind,
61    pub to: LayoutKind,
62    /// Every rename the move needs, in the order it is applied. Empty when nothing had to move.
63    pub moves: Vec<LayoutMove>,
64    /// Files in the root the move does not understand and therefore left where they were.
65    pub unclaimed: Vec<PathBuf>,
66    /// `false` when the database was already in the target arrangement under the target name.
67    pub changed: bool,
68    pub dry_run: bool,
69}
70
71/// The metadata document as it sits on disk, in whichever of the two formats wrote it. A format-1
72/// manifest is kept as the raw document rather than parsed into a type of its own: the move only
73/// ever adds or removes its `layout` member, and carrying the rest through untouched is both
74/// simpler and the only way a member this build does not know survives the move.
75enum StoredMetadata {
76    Manifest(Map<String, Value>),
77    Catalog(Box<Catalog>),
78}
79
80impl StoredMetadata {
81    /// The arrangement the document records. A document with no `layout` at all is flat — every
82    /// database written before layouts existed is.
83    fn layout_kind(&self) -> LayoutKind {
84        match self {
85            StoredMetadata::Manifest(manifest) => match manifest.get("layout").and_then(Value::as_str) {
86                Some("nested") => LayoutKind::Nested,
87                _ => LayoutKind::Flat,
88            },
89            StoredMetadata::Catalog(catalog) => catalog.layout.map_or(LayoutKind::Flat, LayoutKind::from),
90        }
91    }
92}
93
94/// Every path the move has to account for, in one arrangement of one database directory.
95///
96/// A superset of [`Layout`], which is narrowed to the roles this engine writes: a move also has to
97/// carry the two roles only format 1 puts on disk — the per-table `<table>.schema.json` files and
98/// the `pending/` staging directory — because a format-1 database is moved like any other, and is
99/// left in format 1 by the move.
100struct Roles {
101    layout: Layout,
102    schemas: PathBuf,
103    pending: PathBuf,
104}
105
106impl Roles {
107    fn new(root: &Path, kind: LayoutKind, metadata_file: &str) -> Roles {
108        let under = |name: &str| {
109            if kind == LayoutKind::Nested {
110                root.join(name)
111            } else {
112                root.to_path_buf()
113            }
114        };
115        Roles {
116            layout: Layout::new(root, kind, metadata_file),
117            schemas: under("schema"),
118            pending: under("pending"),
119        }
120    }
121
122    fn schema_path(&self, table: &str) -> PathBuf {
123        self.schemas.join(format!("{table}{SCHEMA_SUFFIX}"))
124    }
125
126    /// Directory a partitioned table keeps its per-value logs in — moved whole, as one rename.
127    fn partition_dir(&self, table: &str) -> PathBuf {
128        self.layout.tables().join(table)
129    }
130
131    /// Directories the arrangement needs before anything is renamed into them, and — read the
132    /// other way round — the ones left empty behind a move away from it.
133    fn directories(&self) -> Vec<PathBuf> {
134        if self.layout.kind() == LayoutKind::Nested {
135            vec![
136                self.layout.tables().to_path_buf(),
137                self.schemas.clone(),
138                self.layout.indexes().to_path_buf(),
139                self.layout.runtime().to_path_buf(),
140            ]
141        } else {
142            vec![self.layout.root().to_path_buf()]
143        }
144    }
145}
146
147/// The name two options spell the same metadata document under, refusing the pair that names two
148/// different files: one of the two would otherwise be silently ignored.
149fn metadata_file_of(
150    current: (&str, Option<&str>),
151    legacy: (&str, Option<&str>),
152) -> Result<Option<String>, HaysonSchemaError> {
153    match (current, legacy) {
154        ((current_name, Some(current)), (legacy_name, Some(legacy))) if current != legacy => {
155            Err(HaysonSchemaError(format!(
156                "\"{current_name}\" is \"{current}\" and \"{legacy_name}\" is \"{legacy}\" — \"{legacy_name}\" is the deprecated alias of \"{current_name}\" and both name the same file, so one of the two would be silently ignored; pass only one"
157            )))
158        }
159        ((_, Some(current)), _) => Ok(Some(current.to_string())),
160        ((_, None), (_, legacy)) => Ok(legacy.map(str::to_owned)),
161    }
162}
163
164fn read_stored_metadata(path: &Path) -> Result<Option<StoredMetadata>, DatabaseError> {
165    let raw = match fs::read_to_string(path) {
166        Ok(raw) => raw,
167        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
168        Err(error) => return Err(DatabaseError::Io(error)),
169    };
170    let document: Value = serde_json::from_str(&raw).map_err(|error| {
171        HaysonSchemaError(format!(
172            "\"{}\" is not valid JSON — it may have been left truncated by an interrupted write: {error}",
173            path.display()
174        ))
175    })?;
176    let Value::Object(document) = document else {
177        return Err(HaysonSchemaError(format!("manifest \"{}\" is not a hayson database", path.display())).into());
178    };
179    let marker = document
180        .get("catalogVersion")
181        .or_else(|| document.get("hayson"))
182        .and_then(Value::as_u64);
183    match marker {
184        Some(MANIFEST_MARKER) => Ok(Some(StoredMetadata::Manifest(document))),
185        Some(CATALOG_MARKER) => Ok(Some(StoredMetadata::Catalog(Box::new(parse_catalog(&Value::Object(
186            document,
187        ))?)))),
188        Some(newer) => Err(HaysonSchemaError(format!(
189            "manifest \"{}\" carries marker {newer}, and this build of hayson knows {MANIFEST_MARKER} (schema files) and {CATALOG_MARKER} (metadata catalog) — the database was written by a newer hayson, so upgrade it to open this directory",
190            path.display()
191        ))
192        .into()),
193        None => Err(HaysonSchemaError(format!("manifest \"{}\" is not a hayson database", path.display())).into()),
194    }
195}
196
197/// Every table a format-1 database declares by having a `<table>.schema.json` for it, sorted. An
198/// unreadable directory reads as no tables, exactly as the TS `readdir(...).catch(() => [])` does:
199/// a flat database keeps its schema files in the root, which is never missing here.
200fn schema_file_tables(dir: &Path) -> Vec<String> {
201    let Ok(entries) = fs::read_dir(dir) else {
202        return Vec::new();
203    };
204    let mut tables: Vec<String> = entries
205        .filter_map(Result::ok)
206        .filter_map(|entry| entry.file_name().to_str().map(str::to_owned))
207        .filter_map(|name| name.strip_suffix(SCHEMA_SUFFIX).map(str::to_owned))
208        .filter(|table| !table.is_empty())
209        .collect();
210    tables.sort();
211    tables
212}
213
214#[cfg(unix)]
215fn permissions_of(path: &Path) -> std::io::Result<Option<u32>> {
216    use std::os::unix::fs::PermissionsExt;
217    match fs::metadata(path) {
218        Ok(metadata) => Ok(Some(metadata.permissions().mode() & PERMISSION_BITS)),
219        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
220        Err(error) => Err(error),
221    }
222}
223
224// NOTE: wasi has no permission bits at all; the host sandbox bounds fs access instead.
225#[cfg(not(unix))]
226fn permissions_of(_path: &Path) -> std::io::Result<Option<u32>> {
227    Ok(None)
228}
229
230/// Writes a format-1 manifest the way [`write_catalog`] writes a catalog: `.tmp` on exclusive
231/// terms, `fsync`, `rename`, inheriting the permissions of the file it replaces. Rendered with the
232/// two-space indent and the trailing newline the TS writer used, so a manifest this moves reads
233/// back byte for byte as one hayson has always written.
234fn write_manifest(path: &Path, manifest: &Map<String, Value>) -> Result<(), DatabaseError> {
235    let mode = permissions_of(path)?;
236    let contents = format!(
237        "{}\n",
238        serde_json::to_string_pretty(manifest).map_err(std::io::Error::other)?
239    );
240    write_atomic(path, contents.as_bytes(), mode)?;
241    Ok(())
242}
243
244/// Takes the writer lock of the directory for the length of a move, at the path the arrangement on
245/// disk puts it — `.hayson.lock` in the root of a flat database, `runtime/.hayson.lock` in a
246/// nested one — and names the root, not the lock file, in a refusal.
247///
248/// This is the whole of the mutual exclusion between a move and the rest of the engine: it is the
249/// same [`DirLock`] on the same path that `Database::open` takes, so a move and a writer contend
250/// for one lock file under one protocol, and neither can be running while the other is.
251fn hold_for_move(root: &Path, layout: &Layout, lock: LockMode) -> Result<HeldLock, DatabaseError> {
252    let LockMode::Enabled(options) = lock else {
253        return Ok(HeldLock(None));
254    };
255    // NOTE: as in `open` — a nested database whose runtime/ was cleaned away stays lockable.
256    if root.is_dir() {
257        fs::create_dir_all(layout.runtime())?;
258    }
259    Ok(HeldLock(Some(DirLock::acquire_as(
260        layout.lock_path(),
261        options,
262        root.to_path_buf(),
263    )?)))
264}
265
266/// Moves a database between the `flat` and `nested` on-disk arrangements.
267///
268/// Idempotent: a database already in the target arrangement, under the target document name,
269/// reports `changed: false` and moves nothing. Never automatic — in a repository this is a visible
270/// change to tracked files, so it has to be asked for. Only files the database accounts for are
271/// moved; anything else in the root is reported in `unclaimed` and left alone.
272///
273/// The directory is held under the writer lock for the length of the move (see [`hold_for_move`]),
274/// so nothing here races a writer that has the directory open. The lock is released before the
275/// directories the move emptied are removed, because in a nested database the lock file itself
276/// lives in one of them.
277///
278/// A format-1 database is moved without being migrated: its `<table>.schema.json` files travel with
279/// it and its manifest is left in format 1, so what `open()` says about it afterwards is exactly
280/// what it said before.
281///
282/// @param root the database directory
283/// @param options the arrangement to move into, the document names on either side of the move, and
284/// whether to only report the plan
285/// @returns what was moved, or would be moved under `dry_run`
286pub fn migrate_layout(root: &Path, options: LayoutMigrationOptions) -> Result<LayoutMigrationResult, DatabaseError> {
287    let target = options.to.unwrap_or(LayoutKind::Nested);
288    let from_metadata_file = metadata_file_of(
289        ("fromMetadataFile", options.from_metadata_file.as_deref()),
290        ("fromManifest", options.from_manifest.as_deref()),
291    )?
292    .unwrap_or_else(|| DEFAULT_METADATA_FILE.to_string());
293    let to_metadata_file = metadata_file_of(
294        ("metadataFile", options.metadata_file.as_deref()),
295        ("manifestFile", options.manifest_file.as_deref()),
296    )?
297    .unwrap_or_else(|| from_metadata_file.clone());
298
299    if std::fs::read(root.join(&from_metadata_file)).is_ok_and(|bytes| crate::crypto::is_sealed_blob(&bytes)) {
300        return Err(HaysonSchemaError(format!(
301            "\"{}\" is an encrypted database — moving it between arrangements is not supported yet",
302            root.display()
303        ))
304        .into());
305    }
306    // NOTE: read leniently first — only to learn where the lock lives; the read under it decides.
307    let probed = stored_kind_of(&root.join(&from_metadata_file)).unwrap_or_default();
308    let probed_layout = Layout::new(root, probed, &from_metadata_file);
309    let held = if options.dry_run {
310        HeldLock(None)
311    } else {
312        hold_for_move(root, &probed_layout, options.lock)?
313    };
314
315    let Some(stored) = read_stored_metadata(probed_layout.metadata_path())? else {
316        return Err(HaysonSchemaError(format!(
317            "\"{}\" holds no metadata document \"{from_metadata_file}\" — there is no hayson database here to move",
318            root.display()
319        ))
320        .into());
321    };
322    let current = stored.layout_kind();
323    if !options.dry_run && current != probed {
324        return Err(HaysonSchemaError(format!(
325            "\"{}\" was in the \"{}\" arrangement when the lock for the move was taken and is in the \"{}\" one now — the lock taken is the one the old arrangement names, so nothing was moved; open or move it again",
326            root.display(),
327            probed.as_str(),
328            current.as_str()
329        ))
330        .into());
331    }
332
333    let from = Roles::new(root, current, &from_metadata_file);
334    let to = Roles::new(root, target, &to_metadata_file);
335
336    if current == target && from_metadata_file == to_metadata_file {
337        return Ok(LayoutMigrationResult {
338            root: root.to_path_buf(),
339            from: current,
340            to: target,
341            moves: Vec::new(),
342            unclaimed: Vec::new(),
343            changed: false,
344            dry_run: options.dry_run,
345        });
346    }
347
348    if from.layout.journal_path().exists() {
349        return Err(HaysonSchemaError(format!(
350            "\"{}\" holds an unfinished unit of work — open the database once so it rolls forward, then move it",
351            root.display()
352        ))
353        .into());
354    }
355
356    let tables: Vec<String> = match &stored {
357        StoredMetadata::Catalog(catalog) => catalog.tables.keys().cloned().collect(),
358        StoredMetadata::Manifest(_) => schema_file_tables(&from.schemas),
359    };
360
361    let mut planned: Vec<LayoutMove> = Vec::with_capacity(tables.len() * 4 + 1);
362    for table in &tables {
363        planned.push(LayoutMove {
364            from: from.schema_path(table),
365            to: to.schema_path(table),
366        });
367        planned.push(LayoutMove {
368            from: from.layout.index_path(table),
369            to: to.layout.index_path(table),
370        });
371        planned.push(LayoutMove {
372            from: from.layout.log_path(table),
373            to: to.layout.log_path(table),
374        });
375        planned.push(LayoutMove {
376            from: from.partition_dir(table),
377            to: to.partition_dir(table),
378        });
379    }
380    // NOTE: last, so a table's own refusal is reported before the metadata document's.
381    planned.push(LayoutMove {
382        from: from.layout.metadata_path().to_path_buf(),
383        to: to.layout.metadata_path().to_path_buf(),
384    });
385
386    let mut moves: Vec<LayoutMove> = Vec::new();
387    for pair in planned {
388        if pair.from == pair.to || !pair.from.exists() {
389            continue;
390        }
391        if pair.to.exists() {
392            return Err(HaysonSchemaError(format!(
393                "\"{}\" already exists — refusing to move over it",
394                pair.to.display()
395            ))
396            .into());
397        }
398        moves.push(pair);
399    }
400
401    let unclaimed = unclaimed_in(root, &from, &to, &moves);
402
403    if options.dry_run {
404        return Ok(LayoutMigrationResult {
405            root: root.to_path_buf(),
406            from: current,
407            to: target,
408            moves,
409            unclaimed,
410            changed: true,
411            dry_run: true,
412        });
413    }
414
415    for directory in to.directories() {
416        fs::create_dir_all(&directory)?;
417    }
418    for step in &moves {
419        fs::rename(&step.from, &step.to)?;
420    }
421    match stored {
422        StoredMetadata::Catalog(mut catalog) => {
423            // K4 requires the catalog to carry its layout out loud, `flat` included — never dropped.
424            catalog.layout = Some(CatalogLayout::from(target));
425            write_catalog(to.layout.metadata_path(), &catalog)
426                .map_err(|error| DatabaseError::Catalog(error.to_string()))?;
427        }
428        StoredMetadata::Manifest(mut manifest) => {
429            if target == LayoutKind::Nested {
430                manifest.insert("layout".to_string(), Value::from(target.as_str()));
431            } else {
432                manifest.remove("layout");
433            }
434            write_manifest(to.layout.metadata_path(), &manifest)?;
435        }
436    }
437
438    // NOTE: before the cleanup — in a nested database the lock file lives in one of these.
439    drop(held);
440    let kept = to.directories();
441    for directory in from.directories() {
442        if directory == root || kept.contains(&directory) {
443            continue;
444        }
445        let _ = fs::remove_dir(&directory);
446    }
447
448    Ok(LayoutMigrationResult {
449        root: root.to_path_buf(),
450        from: current,
451        to: target,
452        moves,
453        unclaimed,
454        changed: true,
455        dry_run: false,
456    })
457}
458
459/// Everything in the root the move neither carries nor owns — reported rather than moved, because
460/// a file this cannot name is as likely to be a hand-written note as a stray log, and guessing
461/// which would mean moving somebody's file on a guess.
462fn unclaimed_in(root: &Path, from: &Roles, to: &Roles, moves: &[LayoutMove]) -> Vec<PathBuf> {
463    let claimed: HashSet<&PathBuf> = moves.iter().map(|step| &step.from).collect();
464    let mut structural: HashSet<PathBuf> = from.directories().into_iter().collect();
465    structural.extend(to.directories());
466    structural.insert(from.pending.clone());
467    structural.insert(to.pending.clone());
468    structural.insert(from.layout.metadata_path().to_path_buf());
469    structural.insert(from.layout.lock_path());
470
471    let Ok(entries) = fs::read_dir(root) else {
472        return Vec::new();
473    };
474    let mut unclaimed: Vec<PathBuf> = entries
475        .filter_map(Result::ok)
476        .map(|entry| root.join(entry.file_name()))
477        .filter(|path| !claimed.contains(path) && !structural.contains(path))
478        .collect();
479    unclaimed.sort();
480    unclaimed
481}
482
483#[cfg(test)]
484mod tests {
485    use std::sync::mpsc;
486    use std::thread;
487    use std::time::Duration;
488
489    use serde_json::json;
490    use tempfile::TempDir;
491
492    use super::*;
493    use crate::database::{Database, LayoutOptions, OpenOptions};
494    use crate::lock::{DirLockError, LockOptions, LockState};
495
496    fn users_schema() -> Value {
497        json!({
498            "schemaVersion": 1,
499            "table": "users",
500            "primaryKey": "id",
501            "fields": { "id": { "type": "string" }, "name": { "type": "string" } },
502        })
503    }
504
505    fn seeded(kind: LayoutKind) -> TempDir {
506        let dir = TempDir::new().unwrap();
507        let mut database = Database::open_with(
508            dir.path(),
509            OpenOptions {
510                layout: LayoutOptions {
511                    kind: Some(kind),
512                    ..Default::default()
513                },
514                ..Default::default()
515            },
516        )
517        .unwrap();
518        database.create_table("users", users_schema()).unwrap();
519        database
520            .table_mut("users")
521            .unwrap()
522            .put(&json!({ "id": "u1", "name": "Ania" }), false)
523            .unwrap();
524        database.close().unwrap();
525        dir
526    }
527
528    fn fail_fast() -> LayoutMigrationOptions {
529        LayoutMigrationOptions {
530            lock: LockMode::Enabled(LockOptions {
531                timeout_ms: Some(0),
532                ..Default::default()
533            }),
534            ..Default::default()
535        }
536    }
537
538    #[test]
539    fn the_lock_a_move_takes_is_the_one_an_engine_writer_is_refused_by() {
540        let dir = seeded(LayoutKind::Flat);
541        let layout = Layout::new(dir.path(), LayoutKind::Flat, DEFAULT_METADATA_FILE);
542
543        let held = hold_for_move(dir.path(), &layout, LockMode::default()).unwrap();
544
545        let state = DirLock::inspect(layout.lock_path(), LockOptions::default()).unwrap();
546        assert!(
547            matches!(state, LockState::Held(_)),
548            "a move must leave a live holder in the lock file, got {state:?}"
549        );
550        let refused = match Database::open_with(
551            dir.path(),
552            OpenOptions {
553                lock: LockMode::Enabled(LockOptions {
554                    timeout_ms: Some(0),
555                    ..Default::default()
556                }),
557                ..Default::default()
558            },
559        ) {
560            Ok(_) => panic!("a writer opened a directory the move was holding"),
561            Err(refused) => refused,
562        };
563        match refused {
564            DatabaseError::Lock(DirLockError::Locked(error)) => {
565                assert_eq!(
566                    error.holder.map(|holder| holder.owner),
567                    state.holder().map(|holder| holder.owner.clone()),
568                    "the writer must be refused by the very holder the move wrote"
569                );
570            }
571            other => panic!("expected the writer to be refused by the move's lock, got {other}"),
572        }
573
574        drop(held);
575        Database::open(dir.path()).unwrap().close().unwrap();
576    }
577
578    #[test]
579    fn a_move_is_refused_while_an_engine_writer_holds_the_directory_and_moves_nothing() {
580        let dir = seeded(LayoutKind::Flat);
581        let database = Database::open(dir.path()).unwrap();
582
583        let refused = migrate_layout(dir.path(), fail_fast()).unwrap_err();
584
585        assert!(
586            matches!(refused, DatabaseError::Lock(DirLockError::Locked(_))),
587            "expected the open writer to refuse the move, got {refused}"
588        );
589        assert!(dir.path().join("users.jsonl").is_file(), "the log must not have moved");
590        assert!(!dir.path().join("tables").exists(), "nothing may be created either");
591        database.close().unwrap();
592    }
593
594    #[test]
595    fn a_move_waits_for_an_engine_writer_to_leave_and_then_lands() {
596        let dir = seeded(LayoutKind::Flat);
597        let database = Database::open(dir.path()).unwrap();
598        let (opened, held) = mpsc::channel();
599
600        let writer = thread::spawn(move || {
601            opened.send(()).unwrap();
602            thread::sleep(Duration::from_millis(250));
603            database.close().unwrap();
604        });
605        held.recv().unwrap();
606
607        let started_at = std::time::Instant::now();
608        let moved = migrate_layout(
609            dir.path(),
610            LayoutMigrationOptions {
611                lock: LockMode::Enabled(LockOptions {
612                    timeout_ms: Some(10_000),
613                    retry_interval_ms: Some(10),
614                    ..Default::default()
615                }),
616                ..Default::default()
617            },
618        )
619        .unwrap();
620        writer.join().unwrap();
621
622        assert!(
623            started_at.elapsed() >= Duration::from_millis(200),
624            "the move must have waited the writer out, not run beside it"
625        );
626        assert!(moved.changed);
627        assert!(dir.path().join("tables").join("users.jsonl").is_file());
628    }
629
630    #[test]
631    fn a_dry_run_plans_the_move_without_taking_the_lock_or_touching_the_disk() {
632        let dir = seeded(LayoutKind::Flat);
633        let database = Database::open(dir.path()).unwrap();
634
635        let planned = migrate_layout(
636            dir.path(),
637            LayoutMigrationOptions {
638                dry_run: true,
639                ..fail_fast()
640            },
641        )
642        .unwrap();
643
644        assert!(planned.dry_run && planned.changed);
645        assert!(planned.moves.iter().any(|step| step.from.ends_with("users.jsonl")));
646        assert!(!dir.path().join("tables").exists());
647        database.close().unwrap();
648    }
649
650    #[test]
651    fn a_move_and_a_move_back_leave_the_records_and_the_arrangement_where_they_started() {
652        let dir = seeded(LayoutKind::Flat);
653
654        let there = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap();
655        assert_eq!((there.from, there.to), (LayoutKind::Flat, LayoutKind::Nested));
656        assert!(dir.path().join("tables").join("users.jsonl").is_file());
657        assert!(!dir.path().join("users.jsonl").exists());
658
659        let back = migrate_layout(
660            dir.path(),
661            LayoutMigrationOptions {
662                to: Some(LayoutKind::Flat),
663                ..Default::default()
664            },
665        )
666        .unwrap();
667        assert!(back.changed);
668        for emptied in ["tables", "index", "runtime"] {
669            assert!(!dir.path().join(emptied).exists(), "{emptied} should be gone");
670        }
671
672        let database = Database::open(dir.path()).unwrap();
673        assert_eq!(database.layout().kind(), LayoutKind::Flat);
674        assert_eq!(database.table("users").unwrap().len(), 1);
675    }
676
677    #[test]
678    fn moving_a_database_that_is_already_there_changes_nothing() {
679        let dir = seeded(LayoutKind::Nested);
680
681        let again = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap();
682
683        assert!(!again.changed);
684        assert!(again.moves.is_empty());
685    }
686
687    #[test]
688    fn a_directory_with_no_metadata_document_is_refused_and_left_without_a_lock_file() {
689        let dir = TempDir::new().unwrap();
690
691        let refused = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap_err();
692
693        assert!(refused.to_string().contains("no metadata document"), "{refused}");
694        assert!(
695            !dir.path().join(crate::lock::LOCK_FILE).exists(),
696            "a refused move must take its own lock file with it"
697        );
698    }
699
700    #[test]
701    fn a_truncated_metadata_document_is_refused_naming_the_file() {
702        let dir = TempDir::new().unwrap();
703        fs::write(dir.path().join("hayson.json"), "{\"catalogVersion\": 2, \"tables\": {").unwrap();
704
705        let refused = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap_err();
706
707        assert!(matches!(refused, DatabaseError::Schema(_)), "{refused}");
708        assert!(refused.to_string().contains("hayson.json"), "{refused}");
709    }
710
711    #[test]
712    fn a_taken_target_name_is_refused_before_anything_moves() {
713        let dir = seeded(LayoutKind::Flat);
714        fs::write(dir.path().join("cerebro.json"), "{\"this\":\"is not ours\"}\n").unwrap();
715
716        let refused = migrate_layout(
717            dir.path(),
718            LayoutMigrationOptions {
719                metadata_file: Some("cerebro.json".to_string()),
720                ..Default::default()
721            },
722        )
723        .unwrap_err();
724
725        assert!(refused.to_string().contains("refusing to move over it"), "{refused}");
726        assert_eq!(
727            fs::read_to_string(dir.path().join("cerebro.json")).unwrap(),
728            "{\"this\":\"is not ours\"}\n"
729        );
730        assert!(dir.path().join("users.jsonl").is_file());
731        assert!(!dir.path().join("tables").exists());
732    }
733
734    #[test]
735    fn two_disagreeing_names_for_one_document_are_refused_naming_both_options() {
736        let dir = seeded(LayoutKind::Flat);
737
738        let refused = migrate_layout(
739            dir.path(),
740            LayoutMigrationOptions {
741                from_metadata_file: Some("cerebro.json".to_string()),
742                from_manifest: Some("hayson.json".to_string()),
743                ..Default::default()
744            },
745        )
746        .unwrap_err();
747
748        let message = refused.to_string();
749        assert!(
750            message.contains("fromMetadataFile") && message.contains("fromManifest"),
751            "{message}"
752        );
753    }
754
755    #[test]
756    fn a_file_the_move_does_not_understand_is_reported_rather_than_carried() {
757        let dir = seeded(LayoutKind::Flat);
758        fs::write(dir.path().join("notes.md"), "hand-written\n").unwrap();
759
760        let planned = migrate_layout(
761            dir.path(),
762            LayoutMigrationOptions {
763                dry_run: true,
764                ..Default::default()
765            },
766        )
767        .unwrap();
768
769        assert_eq!(planned.unclaimed, vec![dir.path().join("notes.md")]);
770        assert!(dir.path().join("notes.md").is_file());
771    }
772
773    #[test]
774    fn an_unfinished_unit_of_work_stops_the_move_before_it_starts() {
775        let dir = seeded(LayoutKind::Flat);
776        fs::write(dir.path().join(crate::txn::JOURNAL_FILE), "{}\n").unwrap();
777
778        let refused = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap_err();
779
780        assert!(refused.to_string().contains("unfinished unit of work"), "{refused}");
781        assert!(!dir.path().join("tables").exists());
782    }
783
784    #[test]
785    fn a_format_1_database_moves_with_its_schema_files_and_stays_on_format_1() {
786        let dir = TempDir::new().unwrap();
787        fs::write(
788            dir.path().join("users.schema.json"),
789            serde_json::to_string_pretty(&users_schema()).unwrap(),
790        )
791        .unwrap();
792        fs::write(dir.path().join("users.jsonl"), "").unwrap();
793        let manifest = json!({
794            "hayson": 1,
795            "formatVersion": 1,
796            "createdAt": 1_786_995_100_337u64,
797            "tables": ["users"],
798        });
799        fs::write(
800            dir.path().join("hayson.json"),
801            format!("{}\n", serde_json::to_string_pretty(&manifest).unwrap()),
802        )
803        .unwrap();
804
805        let moved = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap();
806
807        assert!(moved.changed);
808        assert!(dir.path().join("schema").join("users.schema.json").is_file());
809        assert!(dir.path().join("tables").join("users.jsonl").is_file());
810        let written: Value =
811            serde_json::from_str(&fs::read_to_string(dir.path().join("hayson.json")).unwrap()).unwrap();
812        assert_eq!(written["hayson"], json!(1));
813        assert_eq!(written["formatVersion"], json!(1));
814        assert_eq!(written["layout"], json!("nested"));
815        assert_eq!(written["tables"], json!(["users"]));
816    }
817
818    #[test]
819    fn moving_a_format_1_database_back_drops_the_layout_member_instead_of_writing_flat() {
820        let dir = TempDir::new().unwrap();
821        fs::create_dir_all(dir.path().join("schema")).unwrap();
822        fs::write(
823            dir.path().join("schema").join("users.schema.json"),
824            serde_json::to_string_pretty(&users_schema()).unwrap(),
825        )
826        .unwrap();
827        let manifest = json!({
828            "hayson": 1,
829            "formatVersion": 1,
830            "createdAt": 1_786_995_100_337u64,
831            "tables": ["users"],
832            "layout": "nested",
833        });
834        fs::write(
835            dir.path().join("hayson.json"),
836            format!("{}\n", serde_json::to_string_pretty(&manifest).unwrap()),
837        )
838        .unwrap();
839
840        migrate_layout(
841            dir.path(),
842            LayoutMigrationOptions {
843                to: Some(LayoutKind::Flat),
844                ..Default::default()
845            },
846        )
847        .unwrap();
848
849        let written: Value =
850            serde_json::from_str(&fs::read_to_string(dir.path().join("hayson.json")).unwrap()).unwrap();
851        assert!(written.get("layout").is_none(), "a flat manifest names no layout");
852        assert!(dir.path().join("users.schema.json").is_file());
853        assert!(!dir.path().join("schema").exists());
854    }
855
856    #[test]
857    fn a_catalog_names_its_arrangement_out_loud_after_a_move_back_to_flat() {
858        let dir = seeded(LayoutKind::Nested);
859
860        migrate_layout(
861            dir.path(),
862            LayoutMigrationOptions {
863                to: Some(LayoutKind::Flat),
864                ..Default::default()
865            },
866        )
867        .unwrap();
868
869        let written: Value =
870            serde_json::from_str(&fs::read_to_string(dir.path().join("hayson.json")).unwrap()).unwrap();
871        assert_eq!(written["layout"], json!("flat"));
872    }
873
874    #[test]
875    fn renaming_the_document_alone_moves_it_and_nothing_else() {
876        let dir = seeded(LayoutKind::Flat);
877
878        let renamed = migrate_layout(
879            dir.path(),
880            LayoutMigrationOptions {
881                to: Some(LayoutKind::Flat),
882                metadata_file: Some("cerebro.json".to_string()),
883                ..Default::default()
884            },
885        )
886        .unwrap();
887
888        assert_eq!(
889            renamed.moves,
890            vec![LayoutMove {
891                from: dir.path().join("hayson.json"),
892                to: dir.path().join("cerebro.json"),
893            }]
894        );
895        assert!(dir.path().join("cerebro.json").is_file());
896        assert!(!dir.path().join("hayson.json").exists());
897    }
898
899    #[test]
900    fn a_partitioned_table_carries_its_whole_directory_across() {
901        let dir = TempDir::new().unwrap();
902        let mut database = Database::open(dir.path()).unwrap();
903        database
904            .create_table(
905                "notes",
906                json!({
907                    "schemaVersion": 1,
908                    "table": "notes",
909                    "primaryKey": "id",
910                    "partitionBy": "kind",
911                    "fields": {
912                        "id": { "type": "string" },
913                        "kind": { "type": "string", "required": true },
914                    },
915                }),
916            )
917            .unwrap();
918        let notes = database.table_mut("notes").unwrap();
919        notes.put(&json!({ "id": "a", "kind": "adr" }), false).unwrap();
920        notes.put(&json!({ "id": "n", "kind": "note" }), false).unwrap();
921        database.close().unwrap();
922
923        migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap();
924
925        assert!(dir.path().join("tables").join("notes").join("adr.jsonl").is_file());
926        assert!(dir.path().join("tables").join("notes").join("note.jsonl").is_file());
927        let database = Database::open(dir.path()).unwrap();
928        assert_eq!(database.table("notes").unwrap().len(), 2);
929    }
930}