hayson-engine 0.22.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
//! Backup and restore of database directories, from outside the crate.

use std::fs;
use std::path::Path;

use hayson_engine::database::{
    backup_database, restore_database, BackupOptions, Database, RestoreOptions, BACKUP_MANIFEST,
};
use serde_json::json;
use tempfile::TempDir;

fn seeded(root: &Path) {
    let mut database = Database::open(root).unwrap();
    database
        .create_table(
            "docs",
            json!({
                "schemaVersion": 1,
                "table": "docs",
                "primaryKey": "id",
                "fields": { "id": { "type": "string" }, "cover": { "type": "image" } },
            }),
        )
        .unwrap();
    let docs = database.table_mut("docs").unwrap();
    docs.put(
        &json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/a.png" } }),
        false,
    )
    .unwrap();
    database.close().unwrap();
}

fn count(root: &Path) -> usize {
    let database = Database::open(root).unwrap();
    let keys = database.table("docs").unwrap().keys().count();
    database.close().unwrap();
    keys
}

fn add(root: &Path, id: &str, content: &str, path: &str) {
    let mut database = Database::open(root).unwrap();
    database
        .table_mut("docs")
        .unwrap()
        .put(
            &json!({ "id": id, "cover": { "content": content, "virtualPath": path } }),
            false,
        )
        .unwrap();
    database.close().unwrap();
}

#[test]
fn a_backup_restores_into_an_empty_directory_with_data_and_images() {
    let source = TempDir::new().unwrap();
    seeded(source.path());
    let holder = TempDir::new().unwrap();
    let backup = holder.path().join("b1");

    let made = backup_database(source.path(), &backup, &BackupOptions::default()).unwrap();
    assert_eq!(made.files, 3); // hayson.json, docs.jsonl, one image
    assert!(backup.join(BACKUP_MANIFEST).is_file());

    let target = TempDir::new().unwrap();
    let restored = restore_database(&backup, target.path(), &RestoreOptions::default()).unwrap();
    assert_eq!(restored.files, 3);
    assert_eq!(count(target.path()), 1);
    let database = Database::open(target.path()).unwrap();
    let docs = database.table("docs").unwrap();
    let image = &docs.list_images().unwrap()[0];
    assert_eq!(docs.read_image(&image.physical_path).unwrap(), Some(b"bytes".to_vec()));
    database.close().unwrap();
}

#[test]
fn restoring_over_a_database_brings_back_its_earlier_state_and_leaves_subfolders_alone() {
    let root = TempDir::new().unwrap();
    seeded(root.path());
    let holder = TempDir::new().unwrap();
    let backup = holder.path().join("b1");
    backup_database(root.path(), &backup, &BackupOptions::default()).unwrap();

    add(root.path(), "d2", "b3RoZXI=", "/b.png");
    assert_eq!(count(root.path()), 2);
    let child = root.path().join("project");
    fs::create_dir_all(&child).unwrap();
    seeded(&child);

    let restored = restore_database(&backup, root.path(), &RestoreOptions::default()).unwrap();
    assert!(restored.replaced >= 3);
    assert_eq!(count(root.path()), 1);
    assert_eq!(count(&child), 1, "the database in the subfolder is not touched");
    assert!(!root.path().join(".restore-old").exists());
    assert!(!root.path().join(".restore-new").exists());
}

#[test]
fn a_backup_does_not_carry_other_databases_or_runtime_files() {
    let root = TempDir::new().unwrap();
    seeded(root.path());
    let child = root.path().join("project");
    fs::create_dir_all(&child).unwrap();
    seeded(&child);
    let holder = TempDir::new().unwrap();
    let backup = holder.path().join("b1");

    backup_database(root.path(), &backup, &BackupOptions::default()).unwrap();

    assert!(!backup.join("project").exists());
    assert!(!backup.join(".hayson.lock").exists());
}

#[test]
fn a_backup_with_a_tampered_file_is_refused_and_changes_nothing() {
    let root = TempDir::new().unwrap();
    seeded(root.path());
    let holder = TempDir::new().unwrap();
    let backup = holder.path().join("b1");
    backup_database(root.path(), &backup, &BackupOptions::default()).unwrap();
    add(root.path(), "d2", "b3RoZXI=", "/b.png");
    fs::write(backup.join("docs.jsonl"), b"{broken\n").unwrap();

    let refused = restore_database(&backup, root.path(), &RestoreOptions::default());

    assert!(refused.is_err());
    assert_eq!(count(root.path()), 2);
}

#[test]
fn a_failed_install_puts_the_previous_files_back() {
    let root = TempDir::new().unwrap();
    seeded(root.path());
    let holder = TempDir::new().unwrap();
    let backup = holder.path().join("b1");
    backup_database(root.path(), &backup, &BackupOptions::default()).unwrap();
    add(root.path(), "d2", "b3RoZXI=", "/b.png");

    // A manifest entry whose parent directory cannot be created: "extra" is a file in the target.
    fs::write(root.path().join("extra"), b"x").unwrap();
    fs::write(backup.join("extra.txt"), b"y").unwrap();
    let manifest_path = backup.join(BACKUP_MANIFEST);
    let mut manifest: serde_json::Value = serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap()).unwrap();
    fs::create_dir_all(backup.join("nested")).unwrap();
    fs::write(backup.join("nested/f.txt"), b"z").unwrap();
    manifest["files"]
        .as_array_mut()
        .unwrap()
        .push(json!({ "path": "extra/f.txt", "bytes": 1 }));
    fs::create_dir_all(backup.join("extra")).unwrap();
    fs::write(backup.join("extra/f.txt"), b"z").unwrap();
    fs::write(&manifest_path, manifest.to_string()).unwrap();

    let refused = restore_database(&backup, root.path(), &RestoreOptions::default());

    assert!(refused.is_err());
    assert_eq!(count(root.path()), 2, "the database that was there is still there");
    assert!(!root.path().join(".restore-old").exists());
}

#[test]
fn an_interrupted_restore_is_never_overwritten() {
    let root = TempDir::new().unwrap();
    seeded(root.path());
    let holder = TempDir::new().unwrap();
    let backup = holder.path().join("b1");
    backup_database(root.path(), &backup, &BackupOptions::default()).unwrap();
    fs::create_dir_all(root.path().join(".restore-old")).unwrap();

    let refused = restore_database(&backup, root.path(), &RestoreOptions::default()).unwrap_err();

    assert!(refused.to_string().contains(".restore-old"));
}

#[test]
fn a_backup_refuses_a_busy_destination_and_an_unfinished_unit_of_work() {
    let root = TempDir::new().unwrap();
    seeded(root.path());
    let holder = TempDir::new().unwrap();
    let busy = holder.path().join("busy");
    fs::create_dir_all(&busy).unwrap();
    fs::write(busy.join("x"), b"x").unwrap();
    assert!(backup_database(root.path(), &busy, &BackupOptions::default()).is_err());

    fs::write(root.path().join(".hayson.txn"), b"{}").unwrap();
    let dest = holder.path().join("b2");
    let refused = backup_database(root.path(), &dest, &BackupOptions::default()).unwrap_err();
    assert!(refused.to_string().contains("unfinished unit of work"));
    assert!(!dest.exists());
}