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 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 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 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 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
452fn 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}