Skip to main content

hayson_engine/catalog/
migrate.rs

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
59/// Every `<table>.schema.json` a format-1 database at `dir` declares a table through, sorted.
60/// [`migrate_format_1_to_2`] reads exactly these, so the caller that persists the catalog it
61/// returns can retire exactly these once the catalog is safely on disk.
62pub 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    // NOTE: read_dir here, not partition_names_in() — that one reports an unreadable dir as empty.
101    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
115/// Every log file a format-1 table keeps its records in, by partition name, for each of the layout
116/// shapes a format-1 database can be on disk in: flat or `nested` (`docs/format.md`, "Układ
117/// katalogu"), partitioned or not. An empty answer means the table has no log at all — nothing was
118/// ever written to it — and never "the log is somewhere this cannot look".
119fn 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        // NOTE: a partitioned schema whose records were never split yet still keeps one flat log.
139        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
161/// Scans every existing (format-1) log of the table for values that clash on a field declared
162/// `unique` in the candidate schema. Mirrors `uniqueViolationsOfTable` in
163/// `packages/hayson/src/catalog/migrate.ts`, but reads live data through the same `TableRegistry`
164/// the rest of the engine opens a table with, rather than the TS engine's own paged `read(keys)`
165/// callback.
166///
167/// Answers "no violations" only for a table whose logs it did read — a layout it cannot resolve
168/// the logs of is an error, since a silent empty answer would let the migration declare a field
169/// `unique` over data that already breaks it.
170fn 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        // NOTE: readonly open on purpose — this check runs before anything on disk may change.
198        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
230/// Reads every `*.schema.json` of a format-1 database at `dir` and rewrites it into one format-2
231/// `Catalog`, ready for `write_catalog` — the migration itself is left to the caller, which also
232/// decides when `createdAt`/`updatedAt` are pinned. Refuses while `.hayson.txn` sits on disk
233/// (`assertJournalGone` in `packages/hayson/src/catalog/migrate.ts`): an unfinished unit of work is
234/// redone through the layout it was written in, so the format cannot change out from under it.
235/// Also refuses — without writing anything — when a field declared `unique` already has two live
236/// records sharing a value (`uniqueViolationsOf` in migrate.ts).
237pub 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/// Outcome of [`migrate_format1`] on a directory. `AlreadyMigrated` is not an error — the
289/// operation is safe to put in a script that runs on every deployment.
290#[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    // NOTE: "hayson" is the pre-5.0.0 name of "catalogVersion" — a format-1 manifest only has that one.
362    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
372/// Rewrites the format-1 database at `dir` — a `<table>.schema.json` per table, next to the logs —
373/// into the format-2 metadata catalog the engine reads, in place and offline. The catalog lands
374/// first, atomically, and only then do the schema files go: interrupted in between, the directory
375/// is a working format-2 database with residue, which is the state `Database::open` already reads.
376///
377/// Nothing here opens the database, on purpose — the engine refuses format 1, and this is the one
378/// way out of it. Take a copy of the directory first; the rewrite is one-way. Shared by
379/// `hayson-cli`'s `migrate-format1` command and both bindings' `migrateFormat1`.
380pub 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    /// A format-1 `<table>.schema.json`, marker key and all: what sits on the disk being migrated.
425    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}