# bison-db v0.3.0 — Secondary indexes + field and range queries

**Queries arrive.** v0.3.0 adds ordered secondary indexes over document fields,
plus `find` (equality) and `range` queries. You can index any number of fields,
and queries work with or without an index — the index only changes the speed, so
it can never change a result. Zero breaking changes against v0.2.0.

## What is bison-db?

An embedded, document-oriented database for Rust: store schemaless documents
entirely in-process, with no server, no network, and no external services. The
whole database is one file, and writes are appended to a checksummed log, so a
crash never leaves a half-written record behind.

## What's new in 0.3.0

### Secondary indexes — index as many fields as you like

`create_index` builds an ordered index over a field by reading each live document
once; from then on the index is maintained automatically on every insert,
update, and delete. There is no cap on the number of indexed fields — call it
once per field.

```rust
use bison_db::{Db, Document, Value};

fn main() -> bison_db::Result<()> {
    # let path = std::env::temp_dir().join("bison_db_rel_030.bison");
    # let _ = std::fs::remove_file(&path);
    let mut db = Db::open(&path)?;
    for (name, age) in [("ada", 36_i64), ("grace", 45), ("alan", 29)] {
        let mut d = Document::new();
        d.set("name", name).set("age", age);
        db.insert(d)?;
    }

    db.create_index("name")?;
    db.create_index("age")?;
    assert_eq!(db.indexes().count(), 2);
    # let _ = std::fs::remove_file(&path);
    Ok(())
}
```

Indexes are in-memory and rebuilt per session — they are not written to the file,
which keeps the on-disk format free to evolve until it is frozen in v0.4.0. After
reopening a store, call `create_index` again for the fields you want fast.

### Equality and range queries

`find` returns the ids of documents whose field equals a value; `range` returns
those whose field falls within any `RangeBounds<Value>`. Indexed range results
come back ordered by field value.

```rust
use bison_db::{Db, Document, Value};

fn main() -> bison_db::Result<()> {
    # let path = std::env::temp_dir().join("bison_db_rel_030_q.bison");
    # let _ = std::fs::remove_file(&path);
    let mut db = Db::open(&path)?;
    for age in [17_i64, 25, 40, 70] {
        db.insert({ let mut d = Document::new(); d.set("age", age); d })?;
    }
    db.create_index("age")?;

    let ada = db.find("age", &Value::from(40_i64))?;                  // exact
    let working_age = db.range("age", Value::from(18_i64)..=Value::from(65_i64))?; // 25, 40
    assert_eq!(ada.len(), 1);
    assert_eq!(working_age.len(), 2);
    # let _ = std::fs::remove_file(&path);
    Ok(())
}
```

Both queries also run **without** an index, falling back to a full scan, so they
are always available — declaring an index is a performance decision, not a
correctness one. A property test asserts the indexed and scan paths return the
same set for arbitrary data.

### A total order over `Value`

Index keys need a total order, but `Value` holds `f64` (only partially ordered)
and mixes types. v0.3.0 defines one: first by a fixed per-variant rank
(`null < bool < int < float < string < bytes < array < object`), then by natural
order within a variant, using `f64::total_cmp` for floats. The same function
backs both index ordering and query equality, so the two never disagree. One
consequence: integers and floats sit in separate bands, so index a numeric field
with a consistent type.

## Performance

Indicative single-threaded figures from `cargo bench` on a developer laptop
(Linux/WSL2, x86_64, Rust 1.95), against a real on-disk store:

| Operation | Time |
|-----------|------|
| `insert` a small document | ~0.8 µs |
| `get` a small document | ~0.3 µs |
| `find` by indexed field (10k-doc store) | ~60 ns |
| `find` by full scan (10k-doc store) | ~1.6 ms |

The last two rows are the same query with and without an index: the B-tree point
lookup is roughly four orders of magnitude faster than scanning the store. These
are 0.x baselines; a populated head-to-head against other stores is planned for
the 1.0 cycle.

## Breaking changes

**None.** The query methods are purely additive. Internally, `update` and
`delete` now read the previous document when one or more indexes exist, to keep
index entries consistent with document contents; behaviour without indexes is
unchanged.

## Verification

Run on Windows x86_64 and Linux (WSL2 Ubuntu), Rust stable 1.95.x; the same
commands run in the CI matrix across Linux, macOS, and Windows on stable and
MSRV 1.85:

```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo test --all-features
cargo build --no-default-features
cargo build --examples
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
```

All green. Test counts at this tag (`--all-features`): 40 unit + 9 integration +
3 property + 49 doctests.

## What's next

- **v0.4.0 — Write-ahead log + crash recovery + on-disk format freeze.**
  Group-commit durability, a frozen record format, and persistent or
  lazily-rebuilt indexes so they need not be re-declared after reopening.

## Installation

```toml
[dependencies]
bison-db = "0.3"

# With serde support for the document model:
bison-db = { version = "0.3", features = ["serde"] }
```

MSRV: Rust 1.85 (2024 edition).

## Documentation

- [README](https://github.com/jamesgober/bison-db/blob/main/README.md)
- [API Reference](https://github.com/jamesgober/bison-db/blob/main/docs/API.md)
- [Roadmap](https://github.com/jamesgober/bison-db/blob/main/dev/ROADMAP.md)
- [CHANGELOG](https://github.com/jamesgober/bison-db/blob/main/CHANGELOG.md)

---

**Full diff:** [`v0.2.0...v0.3.0`](https://github.com/jamesgober/bison-db/compare/v0.2.0...v0.3.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/bison-db/blob/main/CHANGELOG.md#030---2026-06-07).
