haematite 0.7.0

Content-addressed, branchable, actor-native storage engine
Documentation
//! Migration version matrix + run-twice + abort round-trip + the
//! migrate ≡ fresh-build canonicity witness (CHUNKING-POLICY.md §9).

use std::error::Error;

use crate::db::Database;
use crate::tree::TreePolicy;

use super::test_support::{
    Entries, build_v1_branch, build_v1_fixture, format_version, fresh_build, is_fenced, oversized,
    oversized_shards, read_all_roots, read_branch_head, read_shard_root, v2_default,
};
use super::{MigrateOptions, MigrationDirection, MigrationOutcome, migrate_chunking};

type TestResult = Result<(), Box<dyn Error>>;

/// A v1 directory opens under `V1_DEFAULT` and is NEVER force-migrated at open:
/// the roots and the `format_version = 1` stamp survive a reopen byte-identical
/// (§4.1 — v1/unstamped dirs operate exactly as today, forever).
#[test]
fn v1_fixture_opens_as_v1_without_rewrite() -> TestResult {
    let dir = tempfile::tempdir()?;
    let data = dir.path().join("db");
    let shards = oversized_shards(2, 3);
    let before = build_v1_fixture(&data, &shards)?;

    let opened = Database::open(&data)?;
    assert_eq!(
        opened.tree_policy(),
        TreePolicy::V1_DEFAULT,
        "a v1 directory must open under the v1 policy"
    );
    drop(opened);

    let after = read_all_roots(&data, shards.len())?;
    assert_eq!(
        after,
        before.iter().copied().map(Some).collect::<Vec<_>>(),
        "opening a v1 directory must not rewrite any shard root"
    );
    assert_eq!(
        format_version(&data)?,
        Some(1),
        "open must not restamp the v1 format version"
    );
    Ok(())
}

/// THE canonicity witness (§4.2 step 3, §9): a forward migration rebuilds each
/// shard from empty in ONE batch, so its root EQUALS the fresh-build canonical
/// v2 root of the same map — never a mixed-policy tree. Also proves the shape
/// actually CHANGES (v1 root ≠ v2 root), so the equality is not vacuous.
#[test]
fn migrate_equals_fresh_build_canonical() -> TestResult {
    let dir = tempfile::tempdir()?;
    let data = dir.path().join("db");
    let shards = oversized_shards(2, 3);
    let v1_roots = build_v1_fixture(&data, &shards)?;

    // Sanity: the v2 rebuild is observably different from v1 (else the witness
    // proves nothing).
    for (index, entries) in shards.iter().enumerate() {
        assert_ne!(
            v1_roots[index],
            fresh_build(entries, v2_default())?,
            "shard {index}: v2 shape must differ from v1 for the witness to bite"
        );
    }

    let report = migrate_chunking(&MigrateOptions::migrate(data.clone()))?;
    assert_eq!(report.outcome, MigrationOutcome::MigratedToV2);
    assert_eq!(report.direction, MigrationDirection::Forward);
    assert_eq!(
        report.shards_rebuilt,
        shards.len(),
        "every shard re-chunked"
    );

    for (index, entries) in shards.iter().enumerate() {
        assert_eq!(
            read_shard_root(&data, index)?,
            Some(fresh_build(entries, v2_default())?),
            "shard {index}: migrated root must equal the fresh-build canonical v2 root"
        );
    }
    assert!(
        !is_fenced(&data)?,
        "a completed migration removes the fence"
    );
    assert_eq!(format_version(&data)?, Some(2), "the directory is now v2");
    Ok(())
}

/// Run-twice equality (§9): migrating an already-v2 directory is a no-op — the
/// roots stay byte-identical and the second run reports it did nothing.
#[test]
fn migrate_twice_is_byte_identical() -> TestResult {
    let dir = tempfile::tempdir()?;
    let data = dir.path().join("db");
    let shards = oversized_shards(2, 3);
    build_v1_fixture(&data, &shards)?;

    migrate_chunking(&MigrateOptions::migrate(data.clone()))?;
    let once = read_all_roots(&data, shards.len())?;

    let again = migrate_chunking(&MigrateOptions::migrate(data.clone()))?;
    assert_eq!(
        again.outcome,
        MigrationOutcome::AlreadyInTargetFormat,
        "a second migrate of a v2 directory is a no-op"
    );
    assert_eq!(
        read_all_roots(&data, shards.len())?,
        once,
        "migrating twice must leave the roots byte-identical"
    );
    Ok(())
}

/// A migrated directory opens normally as v2 and mutates under the stamped v2
/// policy (§9 version matrix: migrated-db-then-opens-v2).
#[test]
fn migrated_directory_opens_as_v2() -> TestResult {
    let dir = tempfile::tempdir()?;
    let data = dir.path().join("db");
    build_v1_fixture(&data, &oversized_shards(2, 3))?;
    migrate_chunking(&MigrateOptions::migrate(data.clone()))?;

    let opened = Database::open(&data)?;
    assert!(
        opened.tree_policy().is_v2(),
        "a migrated directory must open under the stamped v2 policy"
    );
    assert_eq!(opened.tree_policy(), v2_default());
    Ok(())
}

/// Aborting a COMPLETED migration runs the durable reverse to v1: the trees are
/// byte-identical to the originals (HI canonicity), and the format flips back to
/// 1 only once every root is v1-shaped (§4.2 step 7).
#[test]
fn abort_of_completed_migration_restores_v1() -> TestResult {
    let dir = tempfile::tempdir()?;
    let data = dir.path().join("db");
    let shards = oversized_shards(2, 3);
    let v1_roots = build_v1_fixture(&data, &shards)?;

    migrate_chunking(&MigrateOptions::migrate(data.clone()))?;
    let report = migrate_chunking(&MigrateOptions::abort(data.clone()))?;

    assert_eq!(report.outcome, MigrationOutcome::AbortedToV1);
    assert_eq!(report.direction, MigrationDirection::Abort);
    assert_eq!(
        read_all_roots(&data, shards.len())?,
        v1_roots.iter().copied().map(Some).collect::<Vec<_>>(),
        "abort must restore byte-identical v1 trees"
    );
    assert_eq!(
        format_version(&data)?,
        Some(1),
        "the format flips back to 1"
    );
    assert!(!is_fenced(&data)?, "abort removes the fence");
    Ok(())
}

/// The headline abort round-trip (§9): v1 → PARTIAL forward (crash mid-run) →
/// abort → v1, byte-identical trees, with the format-flip discipline held (the
/// stamp stays 2 through the whole reverse run until the final v1 publication).
#[test]
fn abort_of_in_flight_migration_restores_v1() -> TestResult {
    let dir = tempfile::tempdir()?;
    let data = dir.path().join("db");
    let shards = oversized_shards(2, 3);
    let v1_roots = build_v1_fixture(&data, &shards)?;

    // A forward run that crashes AFTER shard 0 is committed (shard 0 is v2,
    // shard 1 still v1, the fence is up).
    let interrupted =
        super::migrate_with_crash(&MigrateOptions::migrate(data.clone()), "after_shard_0")?;
    assert!(interrupted.is_none(), "the crash must stop the run early");
    assert!(is_fenced(&data)?, "a partial migration leaves the fence up");
    assert_eq!(
        format_version(&data)?,
        Some(2),
        "the format is 2 while fenced"
    );

    // Abort the fenced directory: flip the direction, reverse every shard to v1.
    let report = migrate_chunking(&MigrateOptions::abort(data.clone()))?;
    assert_eq!(report.outcome, MigrationOutcome::AbortedToV1);
    assert_eq!(
        read_all_roots(&data, shards.len())?,
        v1_roots.iter().copied().map(Some).collect::<Vec<_>>(),
        "abort of a partial migration must restore byte-identical v1 trees"
    );
    assert_eq!(format_version(&data)?, Some(1));
    assert!(!is_fenced(&data)?);
    Ok(())
}

/// Step 4 (§4.2): a branch HEAD is rebuilt the same way a shard is — one batch
/// from empty — and its record advanced, so the migrated head equals the
/// fresh-build canonical v2 head (anchors stay v1-shaped, untouched).
#[test]
fn migrate_rebuilds_branch_heads_canonically() -> TestResult {
    let dir = tempfile::tempdir()?;
    let data = dir.path().join("db");
    build_v1_fixture(&data, &oversized_shards(2, 3))?;
    let puts: Entries = vec![
        (b"branch-0".to_vec(), oversized(50)),
        (b"branch-1".to_vec(), oversized(51)),
    ];
    build_v1_branch(&data, 0, &puts)?;

    // Sanity: the committed v1 head is the v1 canonical build and differs from v2.
    let v1_head = fresh_build(&puts, TreePolicy::V1_DEFAULT)?;
    let v2_head = fresh_build(&puts, v2_default())?;
    assert_eq!(read_branch_head(&data, "main", 0)?, v1_head);
    assert_ne!(
        v1_head, v2_head,
        "the branch head shape must change under v2"
    );

    let report = migrate_chunking(&MigrateOptions::migrate(data.clone()))?;
    assert_eq!(
        report.branch_heads_rebuilt, 1,
        "the branch head must be rebuilt"
    );
    assert_eq!(
        read_branch_head(&data, "main", 0)?,
        v2_head,
        "the migrated branch head must equal the fresh-build canonical v2 head"
    );
    Ok(())
}

/// A directory with only empty shards migrates cleanly to v2 (no entries to
/// re-chunk, but the fence/stamp discipline still runs).
#[test]
fn migrate_of_empty_shards_is_clean() -> TestResult {
    let dir = tempfile::tempdir()?;
    let data = dir.path().join("db");
    build_v1_fixture(&data, &[Vec::new(), Vec::new()])?;

    let report = migrate_chunking(&MigrateOptions::migrate(data.clone()))?;
    assert_eq!(report.outcome, MigrationOutcome::MigratedToV2);
    assert_eq!(report.shard_count, 2);
    assert_eq!(format_version(&data)?, Some(2));
    assert!(!is_fenced(&data)?);
    Ok(())
}