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    // NOTE: read leniently first — only to learn where the lock lives; the read under it decides.
300    let probed = stored_kind_of(&root.join(&from_metadata_file)).unwrap_or_default();
301    let probed_layout = Layout::new(root, probed, &from_metadata_file);
302    let held = if options.dry_run {
303        HeldLock(None)
304    } else {
305        hold_for_move(root, &probed_layout, options.lock)?
306    };
307
308    let Some(stored) = read_stored_metadata(probed_layout.metadata_path())? else {
309        return Err(HaysonSchemaError(format!(
310            "\"{}\" holds no metadata document \"{from_metadata_file}\" — there is no hayson database here to move",
311            root.display()
312        ))
313        .into());
314    };
315    let current = stored.layout_kind();
316    if !options.dry_run && current != probed {
317        return Err(HaysonSchemaError(format!(
318            "\"{}\" 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",
319            root.display(),
320            probed.as_str(),
321            current.as_str()
322        ))
323        .into());
324    }
325
326    let from = Roles::new(root, current, &from_metadata_file);
327    let to = Roles::new(root, target, &to_metadata_file);
328
329    if current == target && from_metadata_file == to_metadata_file {
330        return Ok(LayoutMigrationResult {
331            root: root.to_path_buf(),
332            from: current,
333            to: target,
334            moves: Vec::new(),
335            unclaimed: Vec::new(),
336            changed: false,
337            dry_run: options.dry_run,
338        });
339    }
340
341    if from.layout.journal_path().exists() {
342        return Err(HaysonSchemaError(format!(
343            "\"{}\" holds an unfinished unit of work — open the database once so it rolls forward, then move it",
344            root.display()
345        ))
346        .into());
347    }
348
349    let tables: Vec<String> = match &stored {
350        StoredMetadata::Catalog(catalog) => catalog.tables.keys().cloned().collect(),
351        StoredMetadata::Manifest(_) => schema_file_tables(&from.schemas),
352    };
353
354    let mut planned: Vec<LayoutMove> = Vec::with_capacity(tables.len() * 4 + 1);
355    for table in &tables {
356        planned.push(LayoutMove {
357            from: from.schema_path(table),
358            to: to.schema_path(table),
359        });
360        planned.push(LayoutMove {
361            from: from.layout.index_path(table),
362            to: to.layout.index_path(table),
363        });
364        planned.push(LayoutMove {
365            from: from.layout.log_path(table),
366            to: to.layout.log_path(table),
367        });
368        planned.push(LayoutMove {
369            from: from.partition_dir(table),
370            to: to.partition_dir(table),
371        });
372    }
373    // NOTE: last, so a table's own refusal is reported before the metadata document's.
374    planned.push(LayoutMove {
375        from: from.layout.metadata_path().to_path_buf(),
376        to: to.layout.metadata_path().to_path_buf(),
377    });
378
379    let mut moves: Vec<LayoutMove> = Vec::new();
380    for pair in planned {
381        if pair.from == pair.to || !pair.from.exists() {
382            continue;
383        }
384        if pair.to.exists() {
385            return Err(HaysonSchemaError(format!(
386                "\"{}\" already exists — refusing to move over it",
387                pair.to.display()
388            ))
389            .into());
390        }
391        moves.push(pair);
392    }
393
394    let unclaimed = unclaimed_in(root, &from, &to, &moves);
395
396    if options.dry_run {
397        return Ok(LayoutMigrationResult {
398            root: root.to_path_buf(),
399            from: current,
400            to: target,
401            moves,
402            unclaimed,
403            changed: true,
404            dry_run: true,
405        });
406    }
407
408    for directory in to.directories() {
409        fs::create_dir_all(&directory)?;
410    }
411    for step in &moves {
412        fs::rename(&step.from, &step.to)?;
413    }
414    match stored {
415        StoredMetadata::Catalog(mut catalog) => {
416            // K4 requires the catalog to carry its layout out loud, `flat` included — never dropped.
417            catalog.layout = Some(CatalogLayout::from(target));
418            write_catalog(to.layout.metadata_path(), &catalog)
419                .map_err(|error| DatabaseError::Catalog(error.to_string()))?;
420        }
421        StoredMetadata::Manifest(mut manifest) => {
422            if target == LayoutKind::Nested {
423                manifest.insert("layout".to_string(), Value::from(target.as_str()));
424            } else {
425                manifest.remove("layout");
426            }
427            write_manifest(to.layout.metadata_path(), &manifest)?;
428        }
429    }
430
431    // NOTE: before the cleanup — in a nested database the lock file lives in one of these.
432    drop(held);
433    let kept = to.directories();
434    for directory in from.directories() {
435        if directory == root || kept.contains(&directory) {
436            continue;
437        }
438        let _ = fs::remove_dir(&directory);
439    }
440
441    Ok(LayoutMigrationResult {
442        root: root.to_path_buf(),
443        from: current,
444        to: target,
445        moves,
446        unclaimed,
447        changed: true,
448        dry_run: false,
449    })
450}
451
452/// Everything in the root the move neither carries nor owns — reported rather than moved, because
453/// a file this cannot name is as likely to be a hand-written note as a stray log, and guessing
454/// which would mean moving somebody's file on a guess.
455fn unclaimed_in(root: &Path, from: &Roles, to: &Roles, moves: &[LayoutMove]) -> Vec<PathBuf> {
456    let claimed: HashSet<&PathBuf> = moves.iter().map(|step| &step.from).collect();
457    let mut structural: HashSet<PathBuf> = from.directories().into_iter().collect();
458    structural.extend(to.directories());
459    structural.insert(from.pending.clone());
460    structural.insert(to.pending.clone());
461    structural.insert(from.layout.metadata_path().to_path_buf());
462    structural.insert(from.layout.lock_path());
463
464    let Ok(entries) = fs::read_dir(root) else {
465        return Vec::new();
466    };
467    let mut unclaimed: Vec<PathBuf> = entries
468        .filter_map(Result::ok)
469        .map(|entry| root.join(entry.file_name()))
470        .filter(|path| !claimed.contains(path) && !structural.contains(path))
471        .collect();
472    unclaimed.sort();
473    unclaimed
474}
475
476#[cfg(test)]
477mod tests {
478    use std::sync::mpsc;
479    use std::thread;
480    use std::time::Duration;
481
482    use serde_json::json;
483    use tempfile::TempDir;
484
485    use super::*;
486    use crate::database::{Database, LayoutOptions, OpenOptions};
487    use crate::lock::{DirLockError, LockOptions, LockState};
488
489    fn users_schema() -> Value {
490        json!({
491            "schemaVersion": 1,
492            "table": "users",
493            "primaryKey": "id",
494            "fields": { "id": { "type": "string" }, "name": { "type": "string" } },
495        })
496    }
497
498    fn seeded(kind: LayoutKind) -> TempDir {
499        let dir = TempDir::new().unwrap();
500        let mut database = Database::open_with(
501            dir.path(),
502            OpenOptions {
503                layout: LayoutOptions {
504                    kind: Some(kind),
505                    ..Default::default()
506                },
507                ..Default::default()
508            },
509        )
510        .unwrap();
511        database.create_table("users", users_schema()).unwrap();
512        database
513            .table_mut("users")
514            .unwrap()
515            .put(&json!({ "id": "u1", "name": "Ania" }), false)
516            .unwrap();
517        database.close().unwrap();
518        dir
519    }
520
521    fn fail_fast() -> LayoutMigrationOptions {
522        LayoutMigrationOptions {
523            lock: LockMode::Enabled(LockOptions {
524                timeout_ms: Some(0),
525                ..Default::default()
526            }),
527            ..Default::default()
528        }
529    }
530
531    #[test]
532    fn the_lock_a_move_takes_is_the_one_an_engine_writer_is_refused_by() {
533        let dir = seeded(LayoutKind::Flat);
534        let layout = Layout::new(dir.path(), LayoutKind::Flat, DEFAULT_METADATA_FILE);
535
536        let held = hold_for_move(dir.path(), &layout, LockMode::default()).unwrap();
537
538        let state = DirLock::inspect(layout.lock_path(), LockOptions::default()).unwrap();
539        assert!(
540            matches!(state, LockState::Held(_)),
541            "a move must leave a live holder in the lock file, got {state:?}"
542        );
543        let refused = match Database::open_with(
544            dir.path(),
545            OpenOptions {
546                lock: LockMode::Enabled(LockOptions {
547                    timeout_ms: Some(0),
548                    ..Default::default()
549                }),
550                ..Default::default()
551            },
552        ) {
553            Ok(_) => panic!("a writer opened a directory the move was holding"),
554            Err(refused) => refused,
555        };
556        match refused {
557            DatabaseError::Lock(DirLockError::Locked(error)) => {
558                assert_eq!(
559                    error.holder.map(|holder| holder.owner),
560                    state.holder().map(|holder| holder.owner.clone()),
561                    "the writer must be refused by the very holder the move wrote"
562                );
563            }
564            other => panic!("expected the writer to be refused by the move's lock, got {other}"),
565        }
566
567        drop(held);
568        Database::open(dir.path()).unwrap().close().unwrap();
569    }
570
571    #[test]
572    fn a_move_is_refused_while_an_engine_writer_holds_the_directory_and_moves_nothing() {
573        let dir = seeded(LayoutKind::Flat);
574        let database = Database::open(dir.path()).unwrap();
575
576        let refused = migrate_layout(dir.path(), fail_fast()).unwrap_err();
577
578        assert!(
579            matches!(refused, DatabaseError::Lock(DirLockError::Locked(_))),
580            "expected the open writer to refuse the move, got {refused}"
581        );
582        assert!(dir.path().join("users.jsonl").is_file(), "the log must not have moved");
583        assert!(!dir.path().join("tables").exists(), "nothing may be created either");
584        database.close().unwrap();
585    }
586
587    #[test]
588    fn a_move_waits_for_an_engine_writer_to_leave_and_then_lands() {
589        let dir = seeded(LayoutKind::Flat);
590        let database = Database::open(dir.path()).unwrap();
591        let (opened, held) = mpsc::channel();
592
593        let writer = thread::spawn(move || {
594            opened.send(()).unwrap();
595            thread::sleep(Duration::from_millis(250));
596            database.close().unwrap();
597        });
598        held.recv().unwrap();
599
600        let started_at = std::time::Instant::now();
601        let moved = migrate_layout(
602            dir.path(),
603            LayoutMigrationOptions {
604                lock: LockMode::Enabled(LockOptions {
605                    timeout_ms: Some(10_000),
606                    retry_interval_ms: Some(10),
607                    ..Default::default()
608                }),
609                ..Default::default()
610            },
611        )
612        .unwrap();
613        writer.join().unwrap();
614
615        assert!(
616            started_at.elapsed() >= Duration::from_millis(200),
617            "the move must have waited the writer out, not run beside it"
618        );
619        assert!(moved.changed);
620        assert!(dir.path().join("tables").join("users.jsonl").is_file());
621    }
622
623    #[test]
624    fn a_dry_run_plans_the_move_without_taking_the_lock_or_touching_the_disk() {
625        let dir = seeded(LayoutKind::Flat);
626        let database = Database::open(dir.path()).unwrap();
627
628        let planned = migrate_layout(
629            dir.path(),
630            LayoutMigrationOptions {
631                dry_run: true,
632                ..fail_fast()
633            },
634        )
635        .unwrap();
636
637        assert!(planned.dry_run && planned.changed);
638        assert!(planned.moves.iter().any(|step| step.from.ends_with("users.jsonl")));
639        assert!(!dir.path().join("tables").exists());
640        database.close().unwrap();
641    }
642
643    #[test]
644    fn a_move_and_a_move_back_leave_the_records_and_the_arrangement_where_they_started() {
645        let dir = seeded(LayoutKind::Flat);
646
647        let there = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap();
648        assert_eq!((there.from, there.to), (LayoutKind::Flat, LayoutKind::Nested));
649        assert!(dir.path().join("tables").join("users.jsonl").is_file());
650        assert!(!dir.path().join("users.jsonl").exists());
651
652        let back = migrate_layout(
653            dir.path(),
654            LayoutMigrationOptions {
655                to: Some(LayoutKind::Flat),
656                ..Default::default()
657            },
658        )
659        .unwrap();
660        assert!(back.changed);
661        for emptied in ["tables", "index", "runtime"] {
662            assert!(!dir.path().join(emptied).exists(), "{emptied} should be gone");
663        }
664
665        let database = Database::open(dir.path()).unwrap();
666        assert_eq!(database.layout().kind(), LayoutKind::Flat);
667        assert_eq!(database.table("users").unwrap().len(), 1);
668    }
669
670    #[test]
671    fn moving_a_database_that_is_already_there_changes_nothing() {
672        let dir = seeded(LayoutKind::Nested);
673
674        let again = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap();
675
676        assert!(!again.changed);
677        assert!(again.moves.is_empty());
678    }
679
680    #[test]
681    fn a_directory_with_no_metadata_document_is_refused_and_left_without_a_lock_file() {
682        let dir = TempDir::new().unwrap();
683
684        let refused = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap_err();
685
686        assert!(refused.to_string().contains("no metadata document"), "{refused}");
687        assert!(
688            !dir.path().join(crate::lock::LOCK_FILE).exists(),
689            "a refused move must take its own lock file with it"
690        );
691    }
692
693    #[test]
694    fn a_truncated_metadata_document_is_refused_naming_the_file() {
695        let dir = TempDir::new().unwrap();
696        fs::write(dir.path().join("hayson.json"), "{\"catalogVersion\": 2, \"tables\": {").unwrap();
697
698        let refused = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap_err();
699
700        assert!(matches!(refused, DatabaseError::Schema(_)), "{refused}");
701        assert!(refused.to_string().contains("hayson.json"), "{refused}");
702    }
703
704    #[test]
705    fn a_taken_target_name_is_refused_before_anything_moves() {
706        let dir = seeded(LayoutKind::Flat);
707        fs::write(dir.path().join("cerebro.json"), "{\"this\":\"is not ours\"}\n").unwrap();
708
709        let refused = migrate_layout(
710            dir.path(),
711            LayoutMigrationOptions {
712                metadata_file: Some("cerebro.json".to_string()),
713                ..Default::default()
714            },
715        )
716        .unwrap_err();
717
718        assert!(refused.to_string().contains("refusing to move over it"), "{refused}");
719        assert_eq!(
720            fs::read_to_string(dir.path().join("cerebro.json")).unwrap(),
721            "{\"this\":\"is not ours\"}\n"
722        );
723        assert!(dir.path().join("users.jsonl").is_file());
724        assert!(!dir.path().join("tables").exists());
725    }
726
727    #[test]
728    fn two_disagreeing_names_for_one_document_are_refused_naming_both_options() {
729        let dir = seeded(LayoutKind::Flat);
730
731        let refused = migrate_layout(
732            dir.path(),
733            LayoutMigrationOptions {
734                from_metadata_file: Some("cerebro.json".to_string()),
735                from_manifest: Some("hayson.json".to_string()),
736                ..Default::default()
737            },
738        )
739        .unwrap_err();
740
741        let message = refused.to_string();
742        assert!(
743            message.contains("fromMetadataFile") && message.contains("fromManifest"),
744            "{message}"
745        );
746    }
747
748    #[test]
749    fn a_file_the_move_does_not_understand_is_reported_rather_than_carried() {
750        let dir = seeded(LayoutKind::Flat);
751        fs::write(dir.path().join("notes.md"), "hand-written\n").unwrap();
752
753        let planned = migrate_layout(
754            dir.path(),
755            LayoutMigrationOptions {
756                dry_run: true,
757                ..Default::default()
758            },
759        )
760        .unwrap();
761
762        assert_eq!(planned.unclaimed, vec![dir.path().join("notes.md")]);
763        assert!(dir.path().join("notes.md").is_file());
764    }
765
766    #[test]
767    fn an_unfinished_unit_of_work_stops_the_move_before_it_starts() {
768        let dir = seeded(LayoutKind::Flat);
769        fs::write(dir.path().join(crate::txn::JOURNAL_FILE), "{}\n").unwrap();
770
771        let refused = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap_err();
772
773        assert!(refused.to_string().contains("unfinished unit of work"), "{refused}");
774        assert!(!dir.path().join("tables").exists());
775    }
776
777    #[test]
778    fn a_format_1_database_moves_with_its_schema_files_and_stays_on_format_1() {
779        let dir = TempDir::new().unwrap();
780        fs::write(
781            dir.path().join("users.schema.json"),
782            serde_json::to_string_pretty(&users_schema()).unwrap(),
783        )
784        .unwrap();
785        fs::write(dir.path().join("users.jsonl"), "").unwrap();
786        let manifest = json!({
787            "hayson": 1,
788            "formatVersion": 1,
789            "createdAt": 1_786_995_100_337u64,
790            "tables": ["users"],
791        });
792        fs::write(
793            dir.path().join("hayson.json"),
794            format!("{}\n", serde_json::to_string_pretty(&manifest).unwrap()),
795        )
796        .unwrap();
797
798        let moved = migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap();
799
800        assert!(moved.changed);
801        assert!(dir.path().join("schema").join("users.schema.json").is_file());
802        assert!(dir.path().join("tables").join("users.jsonl").is_file());
803        let written: Value =
804            serde_json::from_str(&fs::read_to_string(dir.path().join("hayson.json")).unwrap()).unwrap();
805        assert_eq!(written["hayson"], json!(1));
806        assert_eq!(written["formatVersion"], json!(1));
807        assert_eq!(written["layout"], json!("nested"));
808        assert_eq!(written["tables"], json!(["users"]));
809    }
810
811    #[test]
812    fn moving_a_format_1_database_back_drops_the_layout_member_instead_of_writing_flat() {
813        let dir = TempDir::new().unwrap();
814        fs::create_dir_all(dir.path().join("schema")).unwrap();
815        fs::write(
816            dir.path().join("schema").join("users.schema.json"),
817            serde_json::to_string_pretty(&users_schema()).unwrap(),
818        )
819        .unwrap();
820        let manifest = json!({
821            "hayson": 1,
822            "formatVersion": 1,
823            "createdAt": 1_786_995_100_337u64,
824            "tables": ["users"],
825            "layout": "nested",
826        });
827        fs::write(
828            dir.path().join("hayson.json"),
829            format!("{}\n", serde_json::to_string_pretty(&manifest).unwrap()),
830        )
831        .unwrap();
832
833        migrate_layout(
834            dir.path(),
835            LayoutMigrationOptions {
836                to: Some(LayoutKind::Flat),
837                ..Default::default()
838            },
839        )
840        .unwrap();
841
842        let written: Value =
843            serde_json::from_str(&fs::read_to_string(dir.path().join("hayson.json")).unwrap()).unwrap();
844        assert!(written.get("layout").is_none(), "a flat manifest names no layout");
845        assert!(dir.path().join("users.schema.json").is_file());
846        assert!(!dir.path().join("schema").exists());
847    }
848
849    #[test]
850    fn a_catalog_names_its_arrangement_out_loud_after_a_move_back_to_flat() {
851        let dir = seeded(LayoutKind::Nested);
852
853        migrate_layout(
854            dir.path(),
855            LayoutMigrationOptions {
856                to: Some(LayoutKind::Flat),
857                ..Default::default()
858            },
859        )
860        .unwrap();
861
862        let written: Value =
863            serde_json::from_str(&fs::read_to_string(dir.path().join("hayson.json")).unwrap()).unwrap();
864        assert_eq!(written["layout"], json!("flat"));
865    }
866
867    #[test]
868    fn renaming_the_document_alone_moves_it_and_nothing_else() {
869        let dir = seeded(LayoutKind::Flat);
870
871        let renamed = migrate_layout(
872            dir.path(),
873            LayoutMigrationOptions {
874                to: Some(LayoutKind::Flat),
875                metadata_file: Some("cerebro.json".to_string()),
876                ..Default::default()
877            },
878        )
879        .unwrap();
880
881        assert_eq!(
882            renamed.moves,
883            vec![LayoutMove {
884                from: dir.path().join("hayson.json"),
885                to: dir.path().join("cerebro.json"),
886            }]
887        );
888        assert!(dir.path().join("cerebro.json").is_file());
889        assert!(!dir.path().join("hayson.json").exists());
890    }
891
892    #[test]
893    fn a_partitioned_table_carries_its_whole_directory_across() {
894        let dir = TempDir::new().unwrap();
895        let mut database = Database::open(dir.path()).unwrap();
896        database
897            .create_table(
898                "notes",
899                json!({
900                    "schemaVersion": 1,
901                    "table": "notes",
902                    "primaryKey": "id",
903                    "partitionBy": "kind",
904                    "fields": {
905                        "id": { "type": "string" },
906                        "kind": { "type": "string", "required": true },
907                    },
908                }),
909            )
910            .unwrap();
911        let notes = database.table_mut("notes").unwrap();
912        notes.put(&json!({ "id": "a", "kind": "adr" }), false).unwrap();
913        notes.put(&json!({ "id": "n", "kind": "note" }), false).unwrap();
914        database.close().unwrap();
915
916        migrate_layout(dir.path(), LayoutMigrationOptions::default()).unwrap();
917
918        assert!(dir.path().join("tables").join("notes").join("adr.jsonl").is_file());
919        assert!(dir.path().join("tables").join("notes").join("note.jsonl").is_file());
920        let database = Database::open(dir.path()).unwrap();
921        assert_eq!(database.table("notes").unwrap().len(), 2);
922    }
923}