obj-core 1.0.2

Storage engine internals for the obj embedded document database (pager, WAL, B-tree, codec, catalog).
Documentation
//! #64 — `Pager::free_page` + `alloc_fresh` route their header
//! updates through the WAL.
//!
//! Pre-#64 contract bug: both methods wrote the file header
//! (`freelist_head`, `page_count`) directly to disk inside an
//! uncommitted Pager txn. A `Pager::open → mutate → drop → reopen`
//! cycle without a user-driven `commit` would therefore leave the
//! on-disk header pointing at WAL-only state, surfacing on reopen as
//! either `Error::Corruption { page_id: 1 }` (when the recorded
//! `freelist_head` references a page whose link bytes never landed)
//! or as a page that exists but reads back wrong bytes.
//!
//! M6.5 #51 routed `set_root_catalog` through the same WAL pathway.
//! #64 closes the loop by extending it to `free_page` and
//! `alloc_fresh` (via `stage_or_write_header`). This test pair pins
//! the post-fix contract: header mutations are atomic with the
//! Pager-txn they belong to, and rolling back a txn rolls back the
//! header as well.

#![forbid(unsafe_code)]

use obj_core::pager::{Config, Pager};
use tempfile::TempDir;

/// A `Db::open` → drop → `Db::open` cycle (no explicit user
/// `commit`) must produce a clean reopen, and the recovered header
/// must reflect the catalog-init txn's committed state (the M6.5
/// #51 wiring already runs the catalog init inside a Pager txn that
/// commits before drop). Post-#64 the `freelist_head` + `page_count`
/// fields likewise ride the WAL, so the catalog-init txn's effects
/// on those fields are durable across the open → drop → open cycle.
#[test]
fn open_drop_open_recovers_post_catalog_init_header_state() {
    let tmp = TempDir::new().expect("tempdir");
    let path = tmp.path().join("hdr_init.obj");

    // Capture the post-catalog-init header state on a fresh open.
    // `Db::from_parts` (mirrored here at the pager layer) wraps
    // `Catalog::open_or_init` in `begin_txn` / `commit` / `end_txn`
    // so the catalog-init B-tree inserts + the page-0 header frame
    // commit atomically.
    let (page_count_after_init, freelist_head_after_init, root_after_init) = {
        let mut p = Pager::open(&path, Config::default()).expect("open fresh");
        p.begin_txn();
        // Allocate two pages then free one to exercise the freelist
        // path inside the init txn — this mutates both `freelist_head`
        // and `page_count`, both of which now ride the WAL via
        // `stage_or_write_header`.
        let a = p.alloc_page().expect("alloc a");
        let _b = p.alloc_page().expect("alloc b");
        p.free_page(a).expect("free a");
        let _ = p.commit().expect("commit init txn");
        p.end_txn();
        (p.page_count(), p.freelist_head(), p.root_catalog())
    };
    assert!(
        page_count_after_init >= 3,
        "page_count grew to at least 3 (page 0 + a + b)"
    );
    assert_eq!(
        freelist_head_after_init, 1,
        "freed page a (=1) is the freelist head"
    );

    // Reopen — recovery must replay the page-0 frame from the WAL
    // (or read the same value from the main file if a checkpoint
    // ran) so the in-memory header matches the pre-drop snapshot.
    let p2 = Pager::open(&path, Config::default()).expect("reopen");
    assert_eq!(
        p2.page_count(),
        page_count_after_init,
        "page_count must survive the open → drop → open cycle",
    );
    assert_eq!(
        p2.freelist_head(),
        freelist_head_after_init,
        "freelist_head must survive the open → drop → open cycle",
    );
    assert_eq!(
        p2.root_catalog(),
        root_after_init,
        "root_catalog must survive the open → drop → open cycle",
    );
}

/// Open a Db. Start a Pager txn. Do `alloc_page` → `free_page`
/// inside it. Drop the txn without committing. Reopen. The recovered
/// header must roll back to the pre-txn state — i.e. the alloc and
/// the free must BOTH be discarded, not just the freelist link page.
#[test]
fn rolled_back_alloc_free_does_not_advance_header_on_reopen() {
    let tmp = TempDir::new().expect("tempdir");
    let path = tmp.path().join("hdr_rollback.obj");

    // First phase: establish a committed baseline header.
    let (baseline_page_count, baseline_freelist_head) = {
        let mut p = Pager::open(&path, Config::default()).expect("open fresh");
        p.begin_txn();
        let _a = p.alloc_page().expect("alloc baseline a");
        let _ = p.commit().expect("commit baseline");
        p.end_txn();
        (p.page_count(), p.freelist_head())
    };
    assert!(
        baseline_page_count >= 2,
        "baseline page_count includes page 0 + a"
    );

    // Second phase: open, do alloc + free in a txn, DROP the pager
    // without commit. Pre-#64 the direct `write_header` calls would
    // have advanced `page_count` and set `freelist_head` on disk
    // before the txn committed; post-#64 the header rides the WAL
    // and the drop discards `header_dirty` cleanly.
    {
        let mut p = Pager::open(&path, Config::default()).expect("open phase 2");
        p.begin_txn();
        let new_page = p.alloc_page().expect("alloc inside uncommitted txn");
        p.free_page(new_page).expect("free inside uncommitted txn");
        // NO commit. Dropping the pager here discards the WAL
        // pending state INCLUDING any staged page-0 header frame.
        drop(p);
    }

    // Third phase: reopen. The header must match the baseline; the
    // uncommitted phase-2 mutations must have left no trace.
    let p = Pager::open(&path, Config::default()).expect("reopen");
    assert_eq!(
        p.page_count(),
        baseline_page_count,
        "uncommitted alloc must NOT advance page_count on disk",
    );
    assert_eq!(
        p.freelist_head(),
        baseline_freelist_head,
        "uncommitted free must NOT advance freelist_head on disk",
    );
}