hayson-engine 0.25.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
//! `verify` from outside the crate: it reports damage as findings and never fails on it.

use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;

use hayson_engine::database::{verify, Database, Severity, VerifyOptions};
use serde_json::json;
use tempfile::TempDir;

fn seeded() -> TempDir {
    let dir = TempDir::new().unwrap();
    let mut database = Database::open(dir.path()).unwrap();
    database
        .create_table(
            "users",
            json!({
                "schemaVersion": 1,
                "table": "users",
                "primaryKey": "id",
                "fields": { "id": { "type": "string" }, "name": { "type": "string" } },
            }),
        )
        .unwrap();
    let users = database.table_mut("users").unwrap();
    users.put(&json!({ "id": "u1", "name": "Ania" }), false).unwrap();
    users.put(&json!({ "id": "u2", "name": "Bartek" }), false).unwrap();
    database.close().unwrap();
    dir
}

fn codes(dir: &Path) -> Vec<&'static str> {
    verify(dir, &VerifyOptions::default())
        .problems
        .iter()
        .map(|p| p.code)
        .collect()
}

fn append(path: &Path, bytes: &[u8]) {
    OpenOptions::new()
        .append(true)
        .open(path)
        .unwrap()
        .write_all(bytes)
        .unwrap();
}

#[test]
fn a_healthy_database_is_clean() {
    let dir = seeded();
    let report = verify(dir.path(), &VerifyOptions::default());
    assert!(report.is_clean(), "{:?}", report.problems);
    assert_eq!(report.tables_checked, 1);
    assert_eq!(report.log_lines_checked, 2);
}

#[test]
fn a_missing_directory_is_a_finding() {
    let dir = TempDir::new().unwrap();
    assert_eq!(codes(&dir.path().join("nope")), ["directory-missing"]);
}

#[test]
fn an_empty_directory_only_warns() {
    let dir = TempDir::new().unwrap();
    let report = verify(dir.path(), &VerifyOptions::default());
    assert_eq!(report.problems.len(), 1);
    assert_eq!(report.problems[0].code, "catalog-missing");
    assert_eq!(report.problems[0].severity, Severity::Warning);
}

#[test]
fn logs_without_a_catalog_are_an_error() {
    let dir = seeded();
    fs::remove_file(dir.path().join("hayson.json")).unwrap();
    let report = verify(dir.path(), &VerifyOptions::default());
    assert_eq!(report.problems[0].code, "catalog-missing");
    assert_eq!(report.problems[0].severity, Severity::Error);
}

#[test]
fn a_damaged_catalog_is_a_finding_not_a_crash() {
    let dir = seeded();
    fs::write(dir.path().join("hayson.json"), "{ not json").unwrap();
    assert_eq!(codes(dir.path()), ["catalog-invalid-json"]);
    fs::write(dir.path().join("hayson.json"), "[1,2]").unwrap();
    assert_eq!(codes(dir.path()), ["catalog-invalid"]);
    fs::write(dir.path().join("hayson.json"), [0xff, 0xfe]).unwrap();
    assert_eq!(codes(dir.path()), ["catalog-unreadable"]);
}

#[test]
fn a_corrupt_log_line_names_its_offset() {
    let dir = seeded();
    let log = dir.path().join("users.jsonl");
    let before = fs::metadata(&log).unwrap().len();
    append(&log, b"{broken\n");
    let report = verify(dir.path(), &VerifyOptions::default());
    let problem = &report.problems[0];
    assert_eq!(problem.code, "log-corrupt-line");
    assert_eq!(problem.table.as_deref(), Some("users"));
    assert_eq!(problem.offset, Some(before));
}

#[test]
fn invalid_utf8_in_a_log_is_a_finding() {
    let dir = seeded();
    append(&dir.path().join("users.jsonl"), &[0xff, 0xfe, b'\n']);
    assert_eq!(codes(dir.path()), ["log-corrupt-line"]);
}

#[test]
fn an_unfinished_tail_is_only_a_warning() {
    let dir = seeded();
    append(&dir.path().join("users.jsonl"), b"{\"_k\":\"u3\"");
    let report = verify(dir.path(), &VerifyOptions::default());
    assert_eq!(report.problems.len(), 1);
    assert_eq!(report.problems[0].code, "log-truncated-tail");
    assert_eq!(report.problems[0].severity, Severity::Warning);
    assert!(!report.has_errors());
}

#[test]
fn a_log_the_catalog_does_not_declare_is_reported() {
    let dir = seeded();
    fs::write(dir.path().join("ghost.jsonl"), "").unwrap();
    assert_eq!(codes(dir.path()), ["log-undeclared"]);
}

#[test]
fn verify_changes_nothing_on_disk() {
    let dir = seeded();
    append(&dir.path().join("users.jsonl"), b"{\"_k\":\"u3\"");
    let snapshot = |p: &Path| {
        let mut files: Vec<_> = fs::read_dir(p)
            .unwrap()
            .map(|e| e.unwrap().path())
            .map(|path| (path.clone(), fs::read(&path).unwrap_or_default()))
            .collect();
        files.sort();
        files
    };
    let before = snapshot(dir.path());
    verify(dir.path(), &VerifyOptions::default());
    assert_eq!(snapshot(dir.path()), before);
}

fn with_images() -> TempDir {
    let dir = TempDir::new().unwrap();
    let mut database = Database::open(dir.path()).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();
    docs.put(
        &json!({ "id": "d2", "cover": { "content": "b3RoZXI=", "virtualPath": "/b.png" } }),
        false,
    )
    .unwrap();
    docs.delete("d2", false).unwrap();
    database.close().unwrap();
    dir
}

fn image_files(dir: &Path) -> Vec<std::path::PathBuf> {
    let mut files: Vec<_> = fs::read_dir(dir.join("docs.images"))
        .unwrap()
        .map(|e| e.unwrap().path())
        .collect();
    files.sort();
    files
}

#[test]
fn an_orphaned_image_is_a_warning() {
    let dir = with_images();
    let report = verify(dir.path(), &VerifyOptions::default());
    assert_eq!(
        report.problems.iter().map(|p| p.code).collect::<Vec<_>>(),
        ["image-orphan"]
    );
    assert_eq!(report.problems[0].severity, Severity::Warning);
}

#[test]
fn a_missing_or_corrupt_image_is_an_error() {
    let dir = with_images();
    let files = image_files(dir.path());
    let referenced = files.iter().find(|path| fs::read(path).unwrap() == b"bytes").unwrap();
    fs::write(referenced, b"tampered").unwrap();
    let codes = codes(dir.path());
    assert!(codes.contains(&"image-corrupt"), "{codes:?}");

    fs::remove_file(referenced).unwrap();
    let codes = self::codes(dir.path());
    assert!(codes.contains(&"image-missing"), "{codes:?}");
}

#[test]
fn an_oversize_image_is_reported_only_with_a_maximum() {
    let dir = with_images();
    assert!(!codes(dir.path()).contains(&"image-oversize"));
    let options = VerifyOptions {
        max_image_bytes: Some(3),
        ..VerifyOptions::default()
    };
    let report = verify(dir.path(), &options);
    assert!(report.problems.iter().any(|p| p.code == "image-oversize"));
}

#[test]
fn image_checks_can_be_switched_off() {
    let dir = with_images();
    let options = VerifyOptions {
        images: false,
        ..VerifyOptions::default()
    };
    assert!(verify(dir.path(), &options).is_clean());
}