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
15const MANIFEST_MARKER: u64 = 1;
18const CATALOG_MARKER: u64 = 2;
19
20#[cfg(unix)]
21const PERMISSION_BITS: u32 = 0o777;
22
23#[derive(Debug, Clone, Default)]
28pub struct LayoutMigrationOptions {
29 pub to: Option<LayoutKind>,
31 pub from_metadata_file: Option<String>,
34 pub from_manifest: Option<String>,
36 pub metadata_file: Option<String>,
38 pub manifest_file: Option<String>,
40 pub dry_run: bool,
43 pub lock: LockMode,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct LayoutMove {
52 pub from: PathBuf,
53 pub to: PathBuf,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct LayoutMigrationResult {
59 pub root: PathBuf,
60 pub from: LayoutKind,
61 pub to: LayoutKind,
62 pub moves: Vec<LayoutMove>,
64 pub unclaimed: Vec<PathBuf>,
66 pub changed: bool,
68 pub dry_run: bool,
69}
70
71enum StoredMetadata {
76 Manifest(Map<String, Value>),
77 Catalog(Box<Catalog>),
78}
79
80impl StoredMetadata {
81 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
94struct 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 fn partition_dir(&self, table: &str) -> PathBuf {
128 self.layout.tables().join(table)
129 }
130
131 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
147fn 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
197fn 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#[cfg(not(unix))]
226fn permissions_of(_path: &Path) -> std::io::Result<Option<u32>> {
227 Ok(None)
228}
229
230fn 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
244fn 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 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
266pub 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 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 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 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 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
459fn 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}