1use std::collections::{BTreeMap, HashSet};
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde_json::Value;
7
8use crate::errors::HaysonSchemaError;
9use crate::registry::table_registry::TableRegistry;
10use crate::schema::parse::with_current_schema_marker;
11use crate::schema::{is_partitioned, parse_schema, TableSchema, UNPARTITIONED};
12use crate::storage::{LogFile, LOG_SUFFIX};
13
14use super::build::table_entry_of;
15use super::types::{Catalog, CatalogLayout, CATALOG_FORMAT_VERSION, CATALOG_VERSION};
16
17const JOURNAL_FILE_NAME: &str = ".hayson.txn";
18const SCHEMA_FILE_SUFFIX: &str = ".schema.json";
19
20fn journal_paths_of(dir: &Path) -> Vec<PathBuf> {
21 vec![dir.join(JOURNAL_FILE_NAME), dir.join("runtime").join(JOURNAL_FILE_NAME)]
22}
23
24fn assert_journal_gone(dir: &Path) -> Result<(), HaysonSchemaError> {
25 for path in journal_paths_of(dir) {
26 if path.exists() {
27 return Err(HaysonSchemaError(format!(
28 "refusing to migrate to the metadata catalog while \"{}\" is on disk: an unfinished unit of work is redone through the layout it was written in, so it has to be finished before the format changes — nothing on disk was changed",
29 path.display()
30 )));
31 }
32 }
33 Ok(())
34}
35
36fn layout_of(dir: &Path) -> CatalogLayout {
37 let manifest = dir.join("hayson.json");
38 let Ok(contents) = fs::read_to_string(&manifest) else {
39 return CatalogLayout::Flat;
40 };
41 let Ok(value) = serde_json::from_str::<Value>(&contents) else {
42 return CatalogLayout::Flat;
43 };
44 match value.get("layout").and_then(Value::as_str) {
45 Some("nested") => CatalogLayout::Nested,
46 _ => CatalogLayout::Flat,
47 }
48}
49
50fn schema_dirs_of(dir: &Path) -> Vec<PathBuf> {
51 let nested = dir.join("schema");
52 if nested.is_dir() {
53 vec![nested]
54 } else {
55 vec![dir.to_path_buf()]
56 }
57}
58
59pub fn schema_files_of(dir: &Path) -> Result<Vec<PathBuf>, HaysonSchemaError> {
63 let mut files = Vec::new();
64 for schema_dir in schema_dirs_of(dir) {
65 let entries = fs::read_dir(&schema_dir).map_err(|error| {
66 HaysonSchemaError(format!("reading \"{}\" to migrate it: {error}", schema_dir.display()))
67 })?;
68 for entry in entries {
69 let entry = entry.map_err(|error| {
70 HaysonSchemaError(format!("reading \"{}\" to migrate it: {error}", schema_dir.display()))
71 })?;
72 let path = entry.path();
73 let is_schema_file = path
74 .file_name()
75 .and_then(|name| name.to_str())
76 .is_some_and(|name| name.ends_with(SCHEMA_FILE_SUFFIX));
77 if is_schema_file {
78 files.push(path);
79 }
80 }
81 }
82 files.sort();
83 Ok(files)
84}
85
86fn tables_dir_of(dir: &Path, layout: CatalogLayout) -> PathBuf {
87 match layout {
88 CatalogLayout::Nested => dir.join("tables"),
89 CatalogLayout::Flat => dir.to_path_buf(),
90 }
91}
92
93fn partition_logs_in(dir: &Path) -> Result<Vec<(String, PathBuf)>, HaysonSchemaError> {
94 let reading = |error: std::io::Error| {
95 HaysonSchemaError(format!(
96 "reading \"{}\" to check unique fields before migrating it: {error}",
97 dir.display()
98 ))
99 };
100 let mut logs = Vec::new();
102 for entry in fs::read_dir(dir).map_err(reading)? {
103 let name = entry.map_err(reading)?.file_name();
104 let Some(part) = name.to_str().and_then(|name| name.strip_suffix(LOG_SUFFIX)) else {
105 continue;
106 };
107 if !part.is_empty() {
108 logs.push((part.to_string(), dir.join(&name)));
109 }
110 }
111 logs.sort();
112 Ok(logs)
113}
114
115fn log_paths_of(
120 dir: &Path,
121 layout: CatalogLayout,
122 schema: &TableSchema,
123) -> Result<Vec<(String, PathBuf)>, HaysonSchemaError> {
124 let tables = tables_dir_of(dir, layout);
125 let flat = tables.join(format!("{}{LOG_SUFFIX}", schema.table));
126 let flat_only = || {
127 if flat.exists() {
128 vec![(UNPARTITIONED.to_string(), flat.clone())]
129 } else {
130 Vec::new()
131 }
132 };
133 if !is_partitioned(schema) {
134 return Ok(flat_only());
135 }
136 let partitions = tables.join(&schema.table);
137 if !partitions.is_dir() {
138 return Ok(flat_only());
140 }
141 let logs = partition_logs_in(&partitions)?;
142 Ok(if logs.is_empty() { flat_only() } else { logs })
143}
144
145struct UniqueViolation {
146 table: String,
147 field: String,
148 keys: Vec<String>,
149}
150
151fn describe_unique_violation(violation: &UniqueViolation) -> String {
152 format!(
153 "field \"{}\" of table \"{}\" is declared unique, and {} record(s) already share a value: {}",
154 violation.field,
155 violation.table,
156 violation.keys.len(),
157 violation.keys.join(", ")
158 )
159}
160
161fn unique_violations_of_table(
171 dir: &Path,
172 layout: CatalogLayout,
173 schema: &TableSchema,
174) -> Result<Vec<UniqueViolation>, HaysonSchemaError> {
175 let unique_fields: Vec<String> = schema
176 .fields
177 .iter()
178 .filter(|(name, spec)| spec.unique && name.as_str() != schema.primary_key)
179 .map(|(name, _)| name.clone())
180 .collect();
181 if unique_fields.is_empty() {
182 return Ok(Vec::new());
183 }
184
185 let logs = log_paths_of(dir, layout, schema)?;
186 if logs.is_empty() {
187 return Ok(Vec::new());
188 }
189 let mut registry = TableRegistry::new(schema.clone());
190 for (part, path) in &logs {
191 let reading = |error: std::io::Error| {
192 HaysonSchemaError(format!(
193 "reading \"{}\" to check unique fields before migrating it: {error}",
194 path.display()
195 ))
196 };
197 let log = LogFile::open(path, true).map_err(reading)?;
199 let scan = log.scan(0).map_err(reading)?;
200 registry.apply_scan_in(scan, part);
201 }
202
203 let mut violations = Vec::new();
204 for field in unique_fields {
205 let mut clashing: HashSet<String> = HashSet::new();
206 for key in registry.keys() {
207 let Some(projection) = registry.projection_of(key) else {
208 continue;
209 };
210 let Some(value) = projection.get(&field) else { continue };
211 if let Some(holders) = registry.lookup_exact(&field, value) {
212 if holders.len() > 1 {
213 clashing.extend(holders.iter().cloned());
214 }
215 }
216 }
217 if !clashing.is_empty() {
218 let mut keys: Vec<String> = clashing.into_iter().collect();
219 keys.sort();
220 violations.push(UniqueViolation {
221 table: schema.table.clone(),
222 field,
223 keys,
224 });
225 }
226 }
227 Ok(violations)
228}
229
230pub fn migrate_format_1_to_2(dir: &Path) -> Result<Catalog, HaysonSchemaError> {
238 assert_journal_gone(dir)?;
239
240 let now = chrono::Utc::now().timestamp_millis() as u64;
241 let layout = layout_of(dir);
242 let mut tables = BTreeMap::new();
243 let mut schemas = Vec::new();
244 for path in schema_files_of(dir)? {
245 let contents = fs::read_to_string(&path)
246 .map_err(|error| HaysonSchemaError(format!("reading \"{}\" to migrate it: {error}", path.display())))?;
247 let document: Value = serde_json::from_str(&contents)
248 .map_err(|error| HaysonSchemaError(format!("\"{}\" is not valid JSON: {error}", path.display())))?;
249 let schema = parse_schema(&with_current_schema_marker(document))?;
250 let entry = table_entry_of(&schema, None, now);
251 tables.insert(schema.table.clone(), entry);
252 schemas.push(schema);
253 }
254
255 let mut violations = Vec::new();
256 for schema in &schemas {
257 violations.extend(unique_violations_of_table(dir, layout, schema)?);
258 }
259 if !violations.is_empty() {
260 let detail = violations
261 .iter()
262 .map(describe_unique_violation)
263 .collect::<Vec<_>>()
264 .join("; ");
265 return Err(HaysonSchemaError(format!(
266 "refusing to migrate \"{}\" to the metadata catalog: {detail} — nothing on disk was changed; make those keys unique (or take the constraint off the schema) and migrate again",
267 dir.display()
268 )));
269 }
270
271 Ok(Catalog {
272 catalog_version: CATALOG_VERSION,
273 format_version: CATALOG_FORMAT_VERSION,
274 created_at: now,
275 updated_at: now,
276 engine: None,
277 layout: Some(layout),
278 defaults: None,
279 tables,
280 relations: None,
281 views: None,
282 triggers: None,
283 })
284}
285
286const CATALOG_FILE: &str = "hayson.json";
287
288#[derive(Debug)]
291pub enum MigrateOutcome {
292 AlreadyMigrated {
293 catalog: PathBuf,
294 },
295 Migrated {
296 catalog: PathBuf,
297 tables: Vec<String>,
298 removed_schema_files: Vec<PathBuf>,
299 },
300}
301
302#[derive(Debug)]
303pub enum MigrateFormat1Error {
304 NotADirectory(PathBuf),
305 UnreadableMarker(PathBuf, String),
306 UnknownMarker(PathBuf, String),
307 NoSchemaFiles(PathBuf),
308 Schema(HaysonSchemaError),
309 Io(std::io::Error),
310 Write(String),
311}
312
313impl fmt::Display for MigrateFormat1Error {
314 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315 match self {
316 MigrateFormat1Error::NotADirectory(path) => {
317 write!(f, "\"{}\" is not a directory — name the database directory itself", path.display())
318 }
319 MigrateFormat1Error::UnreadableMarker(path, detail) => {
320 write!(f, "\"{}\" cannot be read as JSON: {detail} — nothing on disk was changed", path.display())
321 }
322 MigrateFormat1Error::UnknownMarker(path, marker) => write!(
323 f,
324 "\"{}\" carries the marker {marker}, which is neither 1 (the manifest this rewrites) nor 2 (the metadata catalog it writes) — nothing on disk was changed",
325 path.display()
326 ),
327 MigrateFormat1Error::NoSchemaFiles(path) => write!(
328 f,
329 "\"{}\" holds no <table>.schema.json, so there is no format-1 database here to rewrite — nothing on disk was changed",
330 path.display()
331 ),
332 MigrateFormat1Error::Schema(error) => error.fmt(f),
333 MigrateFormat1Error::Io(error) => error.fmt(f),
334 MigrateFormat1Error::Write(detail) => f.write_str(detail),
335 }
336 }
337}
338
339impl std::error::Error for MigrateFormat1Error {}
340
341impl From<HaysonSchemaError> for MigrateFormat1Error {
342 fn from(value: HaysonSchemaError) -> Self {
343 MigrateFormat1Error::Schema(value)
344 }
345}
346
347impl From<std::io::Error> for MigrateFormat1Error {
348 fn from(value: std::io::Error) -> Self {
349 MigrateFormat1Error::Io(value)
350 }
351}
352
353fn marker_of(path: &Path) -> Result<Option<u64>, MigrateFormat1Error> {
354 let raw = match fs::read_to_string(path) {
355 Ok(raw) => raw,
356 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
357 Err(error) => return Err(error.into()),
358 };
359 let document: Value = serde_json::from_str(&raw)
360 .map_err(|error| MigrateFormat1Error::UnreadableMarker(path.to_path_buf(), error.to_string()))?;
361 let marker = document.get("catalogVersion").or_else(|| document.get("hayson"));
363 match marker.and_then(Value::as_u64) {
364 Some(marker) => Ok(Some(marker)),
365 None => Err(MigrateFormat1Error::UnknownMarker(
366 path.to_path_buf(),
367 marker.map_or_else(|| "nothing".to_string(), ToString::to_string),
368 )),
369 }
370}
371
372pub fn migrate_format1(dir: &Path) -> Result<MigrateOutcome, MigrateFormat1Error> {
381 if !dir.is_dir() {
382 return Err(MigrateFormat1Error::NotADirectory(dir.to_path_buf()));
383 }
384 let catalog_path = dir.join(CATALOG_FILE);
385
386 match marker_of(&catalog_path)? {
387 Some(1) | None => {}
388 Some(2) => return Ok(MigrateOutcome::AlreadyMigrated { catalog: catalog_path }),
389 Some(other) => return Err(MigrateFormat1Error::UnknownMarker(catalog_path, other.to_string())),
390 }
391
392 let schema_files = schema_files_of(dir)?;
393 if schema_files.is_empty() {
394 return Err(MigrateFormat1Error::NoSchemaFiles(dir.to_path_buf()));
395 }
396
397 let catalog = migrate_format_1_to_2(dir)?;
398 let tables = catalog.tables.keys().cloned().collect();
399 super::write::write_catalog(&catalog_path, &catalog)
400 .map_err(|error| MigrateFormat1Error::Write(error.to_string()))?;
401
402 let mut removed_schema_files = Vec::new();
403 for path in schema_files {
404 fs::remove_file(&path)?;
405 removed_schema_files.push(path);
406 }
407
408 Ok(MigrateOutcome::Migrated {
409 catalog: catalog_path,
410 tables,
411 removed_schema_files,
412 })
413}
414
415#[cfg(test)]
416mod tests {
417 use serde_json::json;
418 use tempfile::tempdir;
419
420 use super::*;
421 use crate::catalog::parse::parse_catalog;
422 use crate::catalog::serialize::catalog_document;
423
424 fn schema_document(table: &str, fields: Value) -> Value {
426 let mut document = json!({
427 "hayson": 1,
428 "table": table,
429 "primaryKey": "id",
430 "fields": { "id": { "type": "string" } },
431 });
432 for (key, value) in fields.as_object().unwrap() {
433 document["fields"][key] = value.clone();
434 }
435 document
436 }
437
438 fn write_schema_document(dir: &Path, table: &str, document: &Value) {
439 fs::create_dir_all(dir).unwrap();
440 fs::write(
441 dir.join(format!("{table}.schema.json")),
442 serde_json::to_string(document).unwrap(),
443 )
444 .unwrap();
445 }
446
447 fn write_schema_file(dir: &Path, table: &str, fields: Value) {
448 write_schema_document(dir, table, &schema_document(table, fields));
449 }
450
451 fn write_log_at(path: &Path, records: &[(&str, Value)]) {
452 use crate::schema::HaysonRecord;
453 use crate::storage::{serialize_entry, LogMeta, LogOp};
454
455 let mut lines = Vec::new();
456 for (position, (key, fields)) in records.iter().enumerate() {
457 let record: HaysonRecord = fields
458 .as_object()
459 .unwrap()
460 .iter()
461 .map(|(k, v)| (k.clone(), v.clone()))
462 .collect();
463 let meta = LogMeta {
464 key: (*key).to_string(),
465 op: LogOp::Put,
466 seq: position as u64 + 1,
467 ts: 1000 + position as i64,
468 };
469 lines.push(serialize_entry(&meta, Some(&record)));
470 }
471 fs::create_dir_all(path.parent().unwrap()).unwrap();
472 fs::write(path, format!("{}\n", lines.join("\n"))).unwrap();
473 }
474
475 fn write_log_file(dir: &Path, table: &str, records: &[(&str, Value)]) {
476 write_log_at(&dir.join(format!("{table}{LOG_SUFFIX}")), records);
477 }
478
479 #[test]
480 fn migrates_every_schema_file_of_the_directory_into_one_catalog() {
481 let dir = tempdir().unwrap();
482 write_schema_file(dir.path(), "users", json!({ "name": { "type": "string" } }));
483 write_schema_file(dir.path(), "orders", json!({ "userId": { "type": "string" } }));
484
485 let catalog = migrate_format_1_to_2(dir.path()).unwrap();
486
487 assert_eq!(catalog.tables.len(), 2);
488 assert!(catalog.tables.contains_key("users"));
489 assert!(catalog.tables.contains_key("orders"));
490 }
491
492 #[test]
493 fn round_trips_through_serialize_and_parse() {
494 let dir = tempdir().unwrap();
495 write_schema_file(dir.path(), "users", json!({ "name": { "type": "string" } }));
496 write_schema_file(dir.path(), "orders", json!({ "userId": { "type": "string" } }));
497
498 let catalog = migrate_format_1_to_2(dir.path()).unwrap();
499 let reparsed = parse_catalog(&catalog_document(&catalog)).unwrap();
500
501 assert_eq!(reparsed, catalog);
502 }
503
504 #[test]
505 fn refuses_while_a_journal_is_on_disk() {
506 let dir = tempdir().unwrap();
507 write_schema_file(dir.path(), "users", json!({}));
508 fs::write(dir.path().join(".hayson.txn"), "{}").unwrap();
509
510 let error = migrate_format_1_to_2(dir.path()).unwrap_err();
511 assert!(error.0.contains(".hayson.txn"));
512 }
513
514 #[test]
515 fn migrates_when_no_table_declares_a_unique_field() {
516 let dir = tempdir().unwrap();
517 write_schema_file(dir.path(), "users", json!({ "name": { "type": "string" } }));
518 write_log_file(
519 dir.path(),
520 "users",
521 &[
522 ("user_1", json!({ "id": "user_1", "name": "Ania" })),
523 ("user_2", json!({ "id": "user_2", "name": "Ania" })),
524 ],
525 );
526
527 let catalog = migrate_format_1_to_2(dir.path()).unwrap();
528 assert!(catalog.tables.contains_key("users"));
529 }
530
531 #[test]
532 fn migrates_a_unique_field_that_has_no_live_duplicate() {
533 let dir = tempdir().unwrap();
534 write_schema_file(
535 dir.path(),
536 "users",
537 json!({ "email": { "type": "string", "unique": true } }),
538 );
539 write_log_file(
540 dir.path(),
541 "users",
542 &[
543 ("user_1", json!({ "id": "user_1", "email": "ania@example.test" })),
544 ("user_2", json!({ "id": "user_2", "email": "piotr@example.test" })),
545 ],
546 );
547
548 let catalog = migrate_format_1_to_2(dir.path()).unwrap();
549 assert!(catalog.tables.contains_key("users"));
550 }
551
552 #[test]
553 fn refuses_the_whole_migration_when_one_table_has_a_live_unique_duplicate() {
554 let dir = tempdir().unwrap();
555 write_schema_file(
556 dir.path(),
557 "users",
558 json!({ "email": { "type": "string", "unique": true } }),
559 );
560 write_log_file(
561 dir.path(),
562 "users",
563 &[
564 ("user_1", json!({ "id": "user_1", "email": "ania@example.test" })),
565 ("user_2", json!({ "id": "user_2", "email": "ania@example.test" })),
566 ],
567 );
568 write_schema_file(dir.path(), "orders", json!({ "userId": { "type": "string" } }));
569 write_log_file(
570 dir.path(),
571 "orders",
572 &[("order_1", json!({ "id": "order_1", "userId": "user_1" }))],
573 );
574
575 let error = migrate_format_1_to_2(dir.path()).unwrap_err();
576 assert!(error.0.contains("\"email\""));
577 assert!(error.0.contains("\"users\""));
578 assert!(error.0.contains("nothing on disk was changed"));
579 }
580 #[test]
581 fn refuses_a_partitioned_table_whose_partitions_together_hold_a_unique_duplicate() {
582 let dir = tempdir().unwrap();
583 let mut document = schema_document(
584 "users",
585 json!({
586 "kind": { "type": "string", "required": true },
587 "email": { "type": "string", "unique": true },
588 }),
589 );
590 document["partitionBy"] = json!("kind");
591 write_schema_document(dir.path(), "users", &document);
592 write_log_at(
593 &dir.path().join("users").join(format!("staff{LOG_SUFFIX}")),
594 &[(
595 "user_1",
596 json!({ "id": "user_1", "kind": "staff", "email": "ania@example.test" }),
597 )],
598 );
599 write_log_at(
600 &dir.path().join("users").join(format!("guest{LOG_SUFFIX}")),
601 &[(
602 "user_2",
603 json!({ "id": "user_2", "kind": "guest", "email": "ania@example.test" }),
604 )],
605 );
606
607 let error = migrate_format_1_to_2(dir.path()).unwrap_err();
608
609 assert!(error.0.contains("\"email\""), "{}", error.0);
610 assert!(error.0.contains("user_1") && error.0.contains("user_2"), "{}", error.0);
611 assert!(error.0.contains("nothing on disk was changed"), "{}", error.0);
612 }
613
614 #[test]
615 fn migrates_a_partitioned_table_whose_partitions_hold_no_duplicate() {
616 let dir = tempdir().unwrap();
617 let mut document = schema_document(
618 "users",
619 json!({
620 "kind": { "type": "string", "required": true },
621 "email": { "type": "string", "unique": true },
622 }),
623 );
624 document["partitionBy"] = json!("kind");
625 write_schema_document(dir.path(), "users", &document);
626 write_log_at(
627 &dir.path().join("users").join(format!("staff{LOG_SUFFIX}")),
628 &[(
629 "user_1",
630 json!({ "id": "user_1", "kind": "staff", "email": "ania@example.test" }),
631 )],
632 );
633 write_log_at(
634 &dir.path().join("users").join(format!("guest{LOG_SUFFIX}")),
635 &[(
636 "user_2",
637 json!({ "id": "user_2", "kind": "guest", "email": "piotr@example.test" }),
638 )],
639 );
640
641 let catalog = migrate_format_1_to_2(dir.path()).unwrap();
642
643 assert!(catalog.tables.contains_key("users"));
644 }
645
646 #[test]
647 fn refuses_a_nested_layout_table_whose_log_holds_a_unique_duplicate() {
648 let dir = tempdir().unwrap();
649 fs::write(
650 dir.path().join("hayson.json"),
651 json!({ "layout": "nested" }).to_string(),
652 )
653 .unwrap();
654 write_schema_file(
655 &dir.path().join("schema"),
656 "users",
657 json!({ "email": { "type": "string", "unique": true } }),
658 );
659 write_log_file(
660 &dir.path().join("tables"),
661 "users",
662 &[
663 ("user_1", json!({ "id": "user_1", "email": "ania@example.test" })),
664 ("user_2", json!({ "id": "user_2", "email": "ania@example.test" })),
665 ],
666 );
667
668 let error = migrate_format_1_to_2(dir.path()).unwrap_err();
669
670 assert!(error.0.contains("\"email\""), "{}", error.0);
671 assert!(error.0.contains("user_1") && error.0.contains("user_2"), "{}", error.0);
672 assert!(error.0.contains("nothing on disk was changed"), "{}", error.0);
673 }
674
675 #[test]
676 fn migrates_a_nested_layout_table_whose_log_holds_no_duplicate() {
677 let dir = tempdir().unwrap();
678 fs::write(
679 dir.path().join("hayson.json"),
680 json!({ "layout": "nested" }).to_string(),
681 )
682 .unwrap();
683 write_schema_file(
684 &dir.path().join("schema"),
685 "users",
686 json!({ "email": { "type": "string", "unique": true } }),
687 );
688 write_log_file(
689 &dir.path().join("tables"),
690 "users",
691 &[
692 ("user_1", json!({ "id": "user_1", "email": "ania@example.test" })),
693 ("user_2", json!({ "id": "user_2", "email": "piotr@example.test" })),
694 ],
695 );
696
697 let catalog = migrate_format_1_to_2(dir.path()).unwrap();
698
699 assert_eq!(catalog.layout, Some(CatalogLayout::Nested));
700 assert!(catalog.tables.contains_key("users"));
701 }
702
703 fn write_format_1(dir: &Path) {
704 fs::write(
705 dir.join(CATALOG_FILE),
706 "{\n \"hayson\": 1,\n \"tables\": [\"users\"]\n}\n",
707 )
708 .unwrap();
709 fs::write(
710 dir.join("users.schema.json"),
711 serde_json::to_string(&json!({
712 "hayson": 1,
713 "table": "users",
714 "primaryKey": "id",
715 "fields": { "id": { "type": "string" }, "name": { "type": "string" } },
716 }))
717 .unwrap(),
718 )
719 .unwrap();
720 fs::write(
721 dir.join("users.jsonl"),
722 "{\"_k\":\"user_1\",\"_op\":\"put\",\"_seq\":1,\"_ts\":1000,\"id\":\"user_1\",\"name\":\"Ania\"}\n",
723 )
724 .unwrap();
725 }
726
727 #[test]
728 fn migrate_format1_rewrites_a_format_1_directory_into_a_catalog_the_engine_opens() {
729 use crate::database::Database;
730
731 let dir = tempdir().unwrap();
732 write_format_1(dir.path());
733 assert!(Database::open(dir.path()).is_err());
734
735 let outcome = migrate_format1(dir.path()).unwrap();
736
737 let MigrateOutcome::Migrated {
738 tables,
739 removed_schema_files,
740 ..
741 } = outcome
742 else {
743 panic!("a format-1 directory has to report a migration, not a no-op");
744 };
745 assert_eq!(tables, vec!["users".to_string()]);
746 assert_eq!(removed_schema_files, vec![dir.path().join("users.schema.json")]);
747 assert!(!dir.path().join("users.schema.json").exists());
748
749 let db = Database::open(dir.path()).unwrap();
750 assert_eq!(db.table_names(), vec!["users"]);
751 assert_eq!(
752 db.table("users").unwrap().get("user_1").unwrap().unwrap().get("name"),
753 Some(&Value::from("Ania"))
754 );
755 }
756
757 #[test]
758 fn migrate_format1_leaves_the_log_byte_identical() {
759 let dir = tempdir().unwrap();
760 write_format_1(dir.path());
761 let before = fs::read(dir.path().join("users.jsonl")).unwrap();
762
763 migrate_format1(dir.path()).unwrap();
764
765 assert_eq!(fs::read(dir.path().join("users.jsonl")).unwrap(), before);
766 }
767
768 #[test]
769 fn migrate_format1_a_second_run_reports_a_no_op_and_rewrites_nothing() {
770 let dir = tempdir().unwrap();
771 write_format_1(dir.path());
772 migrate_format1(dir.path()).unwrap();
773 let bytes = fs::read_to_string(dir.path().join(CATALOG_FILE)).unwrap();
774
775 let outcome = migrate_format1(dir.path()).unwrap();
776
777 assert!(matches!(outcome, MigrateOutcome::AlreadyMigrated { .. }));
778 assert_eq!(fs::read_to_string(dir.path().join(CATALOG_FILE)).unwrap(), bytes);
779 }
780
781 #[test]
782 fn migrate_format1_refuses_a_directory_holding_no_schema_files_without_writing_a_catalog() {
783 let dir = tempdir().unwrap();
784
785 let failure = migrate_format1(dir.path()).unwrap_err();
786
787 assert!(matches!(failure, MigrateFormat1Error::NoSchemaFiles(_)));
788 assert!(!dir.path().join(CATALOG_FILE).exists());
789 }
790
791 #[test]
792 fn migrate_format1_refuses_a_marker_that_is_neither_1_nor_2_and_keeps_the_schema_files() {
793 let dir = tempdir().unwrap();
794 write_format_1(dir.path());
795 fs::write(dir.path().join(CATALOG_FILE), "{ \"hayson\": 3 }\n").unwrap();
796
797 let failure = migrate_format1(dir.path()).unwrap_err();
798
799 assert!(matches!(failure, MigrateFormat1Error::UnknownMarker(_, _)));
800 assert!(dir.path().join("users.schema.json").exists());
801 }
802
803 #[test]
804 fn migrate_format1_refuses_while_an_unfinished_unit_of_work_is_on_disk() {
805 let dir = tempdir().unwrap();
806 write_format_1(dir.path());
807 fs::write(dir.path().join(".hayson.txn"), "{}\n").unwrap();
808
809 let failure = migrate_format1(dir.path()).unwrap_err();
810
811 assert!(matches!(failure, MigrateFormat1Error::Schema(_)));
812 assert!(failure.to_string().contains(".hayson.txn"));
813 assert!(dir.path().join("users.schema.json").exists());
814 assert_eq!(marker_of(&dir.path().join(CATALOG_FILE)).unwrap(), Some(1));
815 }
816
817 #[test]
818 fn migrate_format1_refuses_a_schema_file_naming_a_table_outside_the_directory_before_writing_anything() {
819 let dir = tempdir().unwrap();
820 write_format_1(dir.path());
821 let schema: Value =
822 serde_json::from_str(&fs::read_to_string(dir.path().join("users.schema.json")).unwrap()).unwrap();
823 let mut traversing = schema.clone();
824 traversing["table"] = Value::from("../outside");
825 fs::write(
826 dir.path().join("outside.schema.json"),
827 serde_json::to_string(&traversing).unwrap(),
828 )
829 .unwrap();
830
831 let failure = migrate_format1(dir.path()).unwrap_err();
832
833 assert!(failure.to_string().contains("unusable table name"));
834 assert_eq!(marker_of(&dir.path().join(CATALOG_FILE)).unwrap(), Some(1));
835 assert!(dir.path().join("users.schema.json").exists());
836 }
837
838 #[cfg(unix)]
839 #[test]
840 fn migrate_format1_writes_the_catalog_no_looser_than_the_manifest_it_replaces() {
841 use std::os::unix::fs::PermissionsExt;
842
843 let dir = tempdir().unwrap();
844 write_format_1(dir.path());
845 let catalog_path = dir.path().join(CATALOG_FILE);
846 fs::set_permissions(&catalog_path, fs::Permissions::from_mode(0o600)).unwrap();
847
848 migrate_format1(dir.path()).unwrap();
849
850 assert_eq!(fs::metadata(&catalog_path).unwrap().permissions().mode() & 0o777, 0o600);
851 }
852
853 #[test]
854 fn migrate_format1_refuses_a_path_that_is_not_a_directory() {
855 let dir = tempdir().unwrap();
856 let file = dir.path().join("hayson.json");
857 fs::write(&file, "{}").unwrap();
858
859 assert!(matches!(
860 migrate_format1(&file).unwrap_err(),
861 MigrateFormat1Error::NotADirectory(_)
862 ));
863 }
864}